···4//! tracked within slices, including batch insertion, querying, and filtering.
56use super::client::Database;
7-use super::types::WhereCondition;
8use crate::errors::DatabaseError;
9use crate::models::Actor;
10-use std::collections::HashMap;
1112impl Database {
13 /// Inserts multiple actors in batches with conflict resolution.
···46 Ok(())
47 }
4849- /// Queries actors for a slice with optional filtering and cursor-based pagination.
50 ///
51- /// Supports filtering by:
52- /// - handle (exact match or contains)
53- /// - did (exact match or IN clause)
54 ///
55 /// # Returns
56 /// Tuple of (actors, next_cursor) where cursor is the last DID
···59 slice_uri: &str,
60 limit: Option<i32>,
61 cursor: Option<&str>,
62- where_conditions: Option<&HashMap<String, WhereCondition>>,
63 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> {
64 let limit = limit.unwrap_or(50).min(100);
6566- let records = if let Some(conditions) = where_conditions {
67- if let Some(handle_condition) = conditions.get("handle") {
68- if let Some(contains_value) = &handle_condition.contains {
69- let pattern = format!("%{}%", contains_value);
70- if let Some(cursor_did) = cursor {
71- sqlx::query_as!(
72- Actor,
73- r#"
74- SELECT did, handle, slice_uri, indexed_at
75- FROM actor
76- WHERE slice_uri = $1 AND handle ILIKE $2 AND did > $3
77- ORDER BY did ASC
78- LIMIT $4
79- "#,
80- slice_uri,
81- pattern,
82- cursor_did,
83- limit as i64
84- )
85- .fetch_all(&self.pool)
86- .await?
87- } else {
88- sqlx::query_as!(
89- Actor,
90- r#"
91- SELECT did, handle, slice_uri, indexed_at
92- FROM actor
93- WHERE slice_uri = $1 AND handle ILIKE $2
94- ORDER BY did ASC
95- LIMIT $3
96- "#,
97- slice_uri,
98- pattern,
99- limit as i64
100- )
101- .fetch_all(&self.pool)
102- .await?
103- }
104- } else if let Some(eq_value) = &handle_condition.eq {
105- let handle_str = eq_value.as_str().unwrap_or("");
106- if let Some(cursor_did) = cursor {
107- sqlx::query_as!(
108- Actor,
109- r#"
110- SELECT did, handle, slice_uri, indexed_at
111- FROM actor
112- WHERE slice_uri = $1 AND handle = $2 AND did > $3
113- ORDER BY did ASC
114- LIMIT $4
115- "#,
116- slice_uri,
117- handle_str,
118- cursor_did,
119- limit as i64
120- )
121- .fetch_all(&self.pool)
122- .await?
123 } else {
124- sqlx::query_as!(
125- Actor,
126- r#"
127- SELECT did, handle, slice_uri, indexed_at
128- FROM actor
129- WHERE slice_uri = $1 AND handle = $2
130- ORDER BY did ASC
131- LIMIT $3
132- "#,
133- slice_uri,
134- handle_str,
135- limit as i64
136- )
137- .fetch_all(&self.pool)
138- .await?
139 }
140- } else {
141- self.query_actors_with_cursor(slice_uri, cursor, limit)
142- .await?
143 }
144- } else if let Some(did_condition) = conditions.get("did") {
145- if let Some(in_values) = &did_condition.in_values {
146- let string_values: Vec<String> = in_values
147 .iter()
148- .filter_map(|v| v.as_str())
149- .map(|s| s.to_string())
150 .collect();
000000151152- sqlx::query_as!(
153- Actor,
154- r#"
155- SELECT did, handle, slice_uri, indexed_at
156- FROM actor
157- WHERE slice_uri = $1 AND did = ANY($2)
158- ORDER BY did ASC
159- LIMIT $3
160- "#,
161- slice_uri,
162- &string_values,
163- limit as i64
164- )
165- .fetch_all(&self.pool)
166- .await?
167- } else if let Some(eq_value) = &did_condition.eq {
168- let did_str = eq_value.as_str().unwrap_or("");
169- if let Some(cursor_did) = cursor {
170- sqlx::query_as!(
171- Actor,
172- r#"
173- SELECT did, handle, slice_uri, indexed_at
174- FROM actor
175- WHERE slice_uri = $1 AND did = $2 AND did > $3
176- ORDER BY did ASC
177- LIMIT $4
178- "#,
179- slice_uri,
180- did_str,
181- cursor_did,
182- limit as i64
183- )
184- .fetch_all(&self.pool)
185- .await?
186- } else {
187- sqlx::query_as!(
188- Actor,
189- r#"
190- SELECT did, handle, slice_uri, indexed_at
191- FROM actor
192- WHERE slice_uri = $1 AND did = $2
193- ORDER BY did ASC
194- LIMIT $3
195- "#,
196- slice_uri,
197- did_str,
198- limit as i64
199- )
200- .fetch_all(&self.pool)
201- .await?
202 }
203- } else {
204- self.query_actors_with_cursor(slice_uri, cursor, limit)
205- .await?
0000000206 }
207- } else {
208- self.query_actors_with_cursor(slice_uri, cursor, limit)
209- .await?
210 }
211- } else {
212- self.query_actors_with_cursor(slice_uri, cursor, limit)
213- .await?
214- };
215216- let cursor = if records.is_empty() {
217- None
0000000000218 } else {
219 records.last().map(|actor| actor.did.clone())
220 };
···222 Ok((records, cursor))
223 }
224225- /// Internal helper for basic actor queries with cursor pagination.
226- async fn query_actors_with_cursor(
227- &self,
228- slice_uri: &str,
229- cursor: Option<&str>,
230- limit: i32,
231- ) -> Result<Vec<Actor>, DatabaseError> {
232- match cursor {
233- Some(cursor_did) => sqlx::query_as!(
234- Actor,
235- r#"
236- SELECT did, handle, slice_uri, indexed_at
237- FROM actor
238- WHERE slice_uri = $1 AND did > $2
239- ORDER BY did ASC
240- LIMIT $3
241- "#,
242- slice_uri,
243- cursor_did,
244- limit as i64
245- )
246- .fetch_all(&self.pool)
247- .await
248- .map_err(DatabaseError::from),
249- None => sqlx::query_as!(
250- Actor,
251- r#"
252- SELECT did, handle, slice_uri, indexed_at
253- FROM actor
254- WHERE slice_uri = $1
255- ORDER BY did ASC
256- LIMIT $2
257- "#,
258- slice_uri,
259- limit as i64
260- )
261- .fetch_all(&self.pool)
262- .await
263- .map_err(DatabaseError::from),
264- }
265- }
266267 /// Gets all actors across all slices.
268 ///
···324 Ok(result.rows_affected())
325 }
326}
0000000000000000000000000000000000000000000000000000000
···4//! tracked within slices, including batch insertion, querying, and filtering.
56use super::client::Database;
7+use super::types::{WhereClause, WhereCondition};
8use crate::errors::DatabaseError;
9use crate::models::Actor;
01011impl Database {
12 /// Inserts multiple actors in batches with conflict resolution.
···45 Ok(())
46 }
4748+ /// Queries actors for a slice with advanced filtering and cursor-based pagination.
49 ///
50+ /// Supports:
51+ /// - Complex WHERE conditions (AND/OR, eq/in/contains operators)
52+ /// - Cursor-based pagination
53 ///
54 /// # Returns
55 /// Tuple of (actors, next_cursor) where cursor is the last DID
···58 slice_uri: &str,
59 limit: Option<i32>,
60 cursor: Option<&str>,
61+ where_clause: Option<&WhereClause>,
62 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> {
63 let limit = limit.unwrap_or(50).min(100);
6465+ let mut where_clauses = vec![format!("slice_uri = $1")];
66+ let mut param_count = 2;
67+68+ // Build WHERE conditions for actors (handle table columns properly)
69+ let (and_conditions, or_conditions) = build_actor_where_conditions(where_clause, &mut param_count);
70+ where_clauses.extend(and_conditions);
71+72+ if !or_conditions.is_empty() {
73+ let or_clause = format!("({})", or_conditions.join(" OR "));
74+ where_clauses.push(or_clause);
75+ }
76+77+ // Add cursor condition
78+ if let Some(_cursor_did) = cursor {
79+ where_clauses.push(format!("did > ${}", param_count));
80+ param_count += 1;
81+ }
82+83+ let where_sql = format!("WHERE {}", where_clauses.join(" AND "));
84+85+ let query = format!(
86+ r#"
87+ SELECT did, handle, slice_uri, indexed_at
88+ FROM actor
89+ {}
90+ ORDER BY did ASC
91+ LIMIT ${}
92+ "#,
93+ where_sql,
94+ param_count
95+ );
96+97+ let mut sqlx_query = sqlx::query_as::<_, Actor>(&query);
98+99+ // Bind parameters in order
100+ sqlx_query = sqlx_query.bind(slice_uri);
101+102+ // Bind WHERE clause parameters
103+ if let Some(clause) = where_clause {
104+ for condition in clause.conditions.values() {
105+ if let Some(eq_value) = &condition.eq {
106+ if let Some(str_val) = eq_value.as_str() {
107+ sqlx_query = sqlx_query.bind(str_val);
00000000000000108 } else {
109+ sqlx_query = sqlx_query.bind(eq_value);
00000000000000110 }
000111 }
112+ if let Some(in_values) = &condition.in_values {
113+ let str_values: Vec<String> = in_values
0114 .iter()
115+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
0116 .collect();
117+ sqlx_query = sqlx_query.bind(str_values);
118+ }
119+ if let Some(contains_value) = &condition.contains {
120+ sqlx_query = sqlx_query.bind(contains_value);
121+ }
122+ }
123124+ // Bind OR conditions
125+ if let Some(or_conditions) = &clause.or_conditions {
126+ for condition in or_conditions.values() {
127+ if let Some(eq_value) = &condition.eq {
128+ if let Some(str_val) = eq_value.as_str() {
129+ sqlx_query = sqlx_query.bind(str_val);
130+ } else {
131+ sqlx_query = sqlx_query.bind(eq_value);
132+ }
00000000000000000000000000000000000000000133 }
134+ if let Some(in_values) = &condition.in_values {
135+ let str_values: Vec<String> = in_values
136+ .iter()
137+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
138+ .collect();
139+ sqlx_query = sqlx_query.bind(str_values);
140+ }
141+ if let Some(contains_value) = &condition.contains {
142+ sqlx_query = sqlx_query.bind(contains_value);
143+ }
144 }
000145 }
146+ }
000147148+ // Bind cursor parameter
149+ if let Some(cursor_did) = cursor {
150+ sqlx_query = sqlx_query.bind(cursor_did);
151+ }
152+153+ // Bind limit
154+ sqlx_query = sqlx_query.bind(limit as i64);
155+156+ let records = sqlx_query.fetch_all(&self.pool).await?;
157+158+ let cursor = if records.len() < limit as usize {
159+ None // Last page - no more results
160 } else {
161 records.last().map(|actor| actor.did.clone())
162 };
···164 Ok((records, cursor))
165 }
16600000000000000000000000000000000000000000167168 /// Gets all actors across all slices.
169 ///
···225 Ok(result.rows_affected())
226 }
227}
228+229+/// Builds WHERE conditions specifically for actor queries.
230+///
231+/// Unlike the general query builder, this handles actor table columns directly
232+/// rather than treating them as JSON paths.
233+fn build_actor_where_conditions(
234+ where_clause: Option<&WhereClause>,
235+ param_count: &mut usize,
236+) -> (Vec<String>, Vec<String>) {
237+ let mut where_clauses = Vec::new();
238+ let mut or_clauses = Vec::new();
239+240+ if let Some(clause) = where_clause {
241+ for (field, condition) in &clause.conditions {
242+ let field_clause = build_actor_single_condition(field, condition, param_count);
243+ if !field_clause.is_empty() {
244+ where_clauses.push(field_clause);
245+ }
246+ }
247+248+ if let Some(or_conditions) = &clause.or_conditions {
249+ for (field, condition) in or_conditions {
250+ let field_clause = build_actor_single_condition(field, condition, param_count);
251+ if !field_clause.is_empty() {
252+ or_clauses.push(field_clause);
253+ }
254+ }
255+ }
256+ }
257+258+ (where_clauses, or_clauses)
259+}
260+261+/// Builds a single SQL condition clause for actor fields.
262+fn build_actor_single_condition(
263+ field: &str,
264+ condition: &WhereCondition,
265+ param_count: &mut usize,
266+) -> String {
267+ if let Some(_eq_value) = &condition.eq {
268+ let clause = format!("{} = ${}", field, param_count);
269+ *param_count += 1;
270+ clause
271+ } else if let Some(_in_values) = &condition.in_values {
272+ let clause = format!("{} = ANY(${})", field, param_count);
273+ *param_count += 1;
274+ clause
275+ } else if let Some(_contains_value) = &condition.contains {
276+ let clause = format!("{} ILIKE '%' || ${} || '%'", field, param_count);
277+ *param_count += 1;
278+ clause
279+ } else {
280+ String::new()
281+ }
282+}
+3-2
api/src/database/records.rs
···342343 let records = query_builder.fetch_all(&self.pool).await?;
344345- let cursor = if records.is_empty() {
346- None
0347 } else {
348 records
349 .last()
···342343 let records = query_builder.fetch_all(&self.pool).await?;
344345+ // Only return cursor if we got a full page, indicating there might be more
346+ let cursor = if records.len() < limit as usize {
347+ None // Last page - no more results
348 } else {
349 records
350 .last()
···1// Generated TypeScript client for AT Protocol records
2-// Generated at: 2025-09-26 18:40:59 UTC
3// Lexicons: 40
45/**
···984 indexedActorCount?: number;
985 /** Number of collections with indexed records */
986 indexedCollectionCount?: number;
0000987}
988989export interface NetworkSlicesSliceDefsSparklinePoint {
···1// Generated TypeScript client for AT Protocol records
2+// Generated at: 2025-09-28 00:41:14 UTC
3// Lexicons: 40
45/**
···984 indexedActorCount?: number;
985 /** Number of collections with indexed records */
986 indexedCollectionCount?: number;
987+ /** Total number of waitlist requests for this slice */
988+ waitlistRequestCount?: number;
989+ /** Total number of waitlist invites for this slice */
990+ waitlistInviteCount?: number;
991}
992993export interface NetworkSlicesSliceDefsSparklinePoint {
···50 "indexedCollectionCount": {
51 "type": "integer",
52 "description": "Number of collections with indexed records"
0000000053 }
54 }
55 },
···50 "indexedCollectionCount": {
51 "type": "integer",
52 "description": "Number of collections with indexed records"
53+ },
54+ "waitlistRequestCount": {
55+ "type": "integer",
56+ "description": "Total number of waitlist requests for this slice"
57+ },
58+ "waitlistInviteCount": {
59+ "type": "integer",
60+ "description": "Total number of waitlist invites for this slice"
61 }
62 }
63 },
+5-1
packages/cli/src/generated_client.ts
···1// Generated TypeScript client for AT Protocol records
2-// Generated at: 2025-09-26 18:21:58 UTC
3// Lexicons: 40
45/**
···984 indexedActorCount?: number;
985 /** Number of collections with indexed records */
986 indexedCollectionCount?: number;
0000987}
988989export interface NetworkSlicesSliceDefsSparklinePoint {
···1// Generated TypeScript client for AT Protocol records
2+// Generated at: 2025-09-28 00:41:51 UTC
3// Lexicons: 40
45/**
···984 indexedActorCount?: number;
985 /** Number of collections with indexed records */
986 indexedCollectionCount?: number;
987+ /** Total number of waitlist requests for this slice */
988+ waitlistRequestCount?: number;
989+ /** Total number of waitlist invites for this slice */
990+ waitlistInviteCount?: number;
991}
992993export interface NetworkSlicesSliceDefsSparklinePoint {