···44//! tracked within slices, including batch insertion, querying, and filtering.
5566use super::client::Database;
77-use super::types::WhereCondition;
77+use super::types::{WhereClause, WhereCondition};
88use crate::errors::DatabaseError;
99use crate::models::Actor;
1010-use std::collections::HashMap;
11101211impl Database {
1312 /// Inserts multiple actors in batches with conflict resolution.
···4645 Ok(())
4746 }
48474949- /// Queries actors for a slice with optional filtering and cursor-based pagination.
4848+ /// Queries actors for a slice with advanced filtering and cursor-based pagination.
5049 ///
5151- /// Supports filtering by:
5252- /// - handle (exact match or contains)
5353- /// - did (exact match or IN clause)
5050+ /// Supports:
5151+ /// - Complex WHERE conditions (AND/OR, eq/in/contains operators)
5252+ /// - Cursor-based pagination
5453 ///
5554 /// # Returns
5655 /// Tuple of (actors, next_cursor) where cursor is the last DID
···5958 slice_uri: &str,
6059 limit: Option<i32>,
6160 cursor: Option<&str>,
6262- where_conditions: Option<&HashMap<String, WhereCondition>>,
6161+ where_clause: Option<&WhereClause>,
6362 ) -> Result<(Vec<Actor>, Option<String>), DatabaseError> {
6463 let limit = limit.unwrap_or(50).min(100);
65646666- let records = if let Some(conditions) = where_conditions {
6767- if let Some(handle_condition) = conditions.get("handle") {
6868- if let Some(contains_value) = &handle_condition.contains {
6969- let pattern = format!("%{}%", contains_value);
7070- if let Some(cursor_did) = cursor {
7171- sqlx::query_as!(
7272- Actor,
7373- r#"
7474- SELECT did, handle, slice_uri, indexed_at
7575- FROM actor
7676- WHERE slice_uri = $1 AND handle ILIKE $2 AND did > $3
7777- ORDER BY did ASC
7878- LIMIT $4
7979- "#,
8080- slice_uri,
8181- pattern,
8282- cursor_did,
8383- limit as i64
8484- )
8585- .fetch_all(&self.pool)
8686- .await?
8787- } else {
8888- sqlx::query_as!(
8989- Actor,
9090- r#"
9191- SELECT did, handle, slice_uri, indexed_at
9292- FROM actor
9393- WHERE slice_uri = $1 AND handle ILIKE $2
9494- ORDER BY did ASC
9595- LIMIT $3
9696- "#,
9797- slice_uri,
9898- pattern,
9999- limit as i64
100100- )
101101- .fetch_all(&self.pool)
102102- .await?
103103- }
104104- } else if let Some(eq_value) = &handle_condition.eq {
105105- let handle_str = eq_value.as_str().unwrap_or("");
106106- if let Some(cursor_did) = cursor {
107107- sqlx::query_as!(
108108- Actor,
109109- r#"
110110- SELECT did, handle, slice_uri, indexed_at
111111- FROM actor
112112- WHERE slice_uri = $1 AND handle = $2 AND did > $3
113113- ORDER BY did ASC
114114- LIMIT $4
115115- "#,
116116- slice_uri,
117117- handle_str,
118118- cursor_did,
119119- limit as i64
120120- )
121121- .fetch_all(&self.pool)
122122- .await?
6565+ let mut where_clauses = vec![format!("slice_uri = $1")];
6666+ let mut param_count = 2;
6767+6868+ // Build WHERE conditions for actors (handle table columns properly)
6969+ let (and_conditions, or_conditions) = build_actor_where_conditions(where_clause, &mut param_count);
7070+ where_clauses.extend(and_conditions);
7171+7272+ if !or_conditions.is_empty() {
7373+ let or_clause = format!("({})", or_conditions.join(" OR "));
7474+ where_clauses.push(or_clause);
7575+ }
7676+7777+ // Add cursor condition
7878+ if let Some(_cursor_did) = cursor {
7979+ where_clauses.push(format!("did > ${}", param_count));
8080+ param_count += 1;
8181+ }
8282+8383+ let where_sql = format!("WHERE {}", where_clauses.join(" AND "));
8484+8585+ let query = format!(
8686+ r#"
8787+ SELECT did, handle, slice_uri, indexed_at
8888+ FROM actor
8989+ {}
9090+ ORDER BY did ASC
9191+ LIMIT ${}
9292+ "#,
9393+ where_sql,
9494+ param_count
9595+ );
9696+9797+ let mut sqlx_query = sqlx::query_as::<_, Actor>(&query);
9898+9999+ // Bind parameters in order
100100+ sqlx_query = sqlx_query.bind(slice_uri);
101101+102102+ // Bind WHERE clause parameters
103103+ if let Some(clause) = where_clause {
104104+ for condition in clause.conditions.values() {
105105+ if let Some(eq_value) = &condition.eq {
106106+ if let Some(str_val) = eq_value.as_str() {
107107+ sqlx_query = sqlx_query.bind(str_val);
123108 } else {
124124- sqlx::query_as!(
125125- Actor,
126126- r#"
127127- SELECT did, handle, slice_uri, indexed_at
128128- FROM actor
129129- WHERE slice_uri = $1 AND handle = $2
130130- ORDER BY did ASC
131131- LIMIT $3
132132- "#,
133133- slice_uri,
134134- handle_str,
135135- limit as i64
136136- )
137137- .fetch_all(&self.pool)
138138- .await?
109109+ sqlx_query = sqlx_query.bind(eq_value);
139110 }
140140- } else {
141141- self.query_actors_with_cursor(slice_uri, cursor, limit)
142142- .await?
143111 }
144144- } else if let Some(did_condition) = conditions.get("did") {
145145- if let Some(in_values) = &did_condition.in_values {
146146- let string_values: Vec<String> = in_values
112112+ if let Some(in_values) = &condition.in_values {
113113+ let str_values: Vec<String> = in_values
147114 .iter()
148148- .filter_map(|v| v.as_str())
149149- .map(|s| s.to_string())
115115+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
150116 .collect();
117117+ sqlx_query = sqlx_query.bind(str_values);
118118+ }
119119+ if let Some(contains_value) = &condition.contains {
120120+ sqlx_query = sqlx_query.bind(contains_value);
121121+ }
122122+ }
151123152152- sqlx::query_as!(
153153- Actor,
154154- r#"
155155- SELECT did, handle, slice_uri, indexed_at
156156- FROM actor
157157- WHERE slice_uri = $1 AND did = ANY($2)
158158- ORDER BY did ASC
159159- LIMIT $3
160160- "#,
161161- slice_uri,
162162- &string_values,
163163- limit as i64
164164- )
165165- .fetch_all(&self.pool)
166166- .await?
167167- } else if let Some(eq_value) = &did_condition.eq {
168168- let did_str = eq_value.as_str().unwrap_or("");
169169- if let Some(cursor_did) = cursor {
170170- sqlx::query_as!(
171171- Actor,
172172- r#"
173173- SELECT did, handle, slice_uri, indexed_at
174174- FROM actor
175175- WHERE slice_uri = $1 AND did = $2 AND did > $3
176176- ORDER BY did ASC
177177- LIMIT $4
178178- "#,
179179- slice_uri,
180180- did_str,
181181- cursor_did,
182182- limit as i64
183183- )
184184- .fetch_all(&self.pool)
185185- .await?
186186- } else {
187187- sqlx::query_as!(
188188- Actor,
189189- r#"
190190- SELECT did, handle, slice_uri, indexed_at
191191- FROM actor
192192- WHERE slice_uri = $1 AND did = $2
193193- ORDER BY did ASC
194194- LIMIT $3
195195- "#,
196196- slice_uri,
197197- did_str,
198198- limit as i64
199199- )
200200- .fetch_all(&self.pool)
201201- .await?
124124+ // Bind OR conditions
125125+ if let Some(or_conditions) = &clause.or_conditions {
126126+ for condition in or_conditions.values() {
127127+ if let Some(eq_value) = &condition.eq {
128128+ if let Some(str_val) = eq_value.as_str() {
129129+ sqlx_query = sqlx_query.bind(str_val);
130130+ } else {
131131+ sqlx_query = sqlx_query.bind(eq_value);
132132+ }
202133 }
203203- } else {
204204- self.query_actors_with_cursor(slice_uri, cursor, limit)
205205- .await?
134134+ if let Some(in_values) = &condition.in_values {
135135+ let str_values: Vec<String> = in_values
136136+ .iter()
137137+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
138138+ .collect();
139139+ sqlx_query = sqlx_query.bind(str_values);
140140+ }
141141+ if let Some(contains_value) = &condition.contains {
142142+ sqlx_query = sqlx_query.bind(contains_value);
143143+ }
206144 }
207207- } else {
208208- self.query_actors_with_cursor(slice_uri, cursor, limit)
209209- .await?
210145 }
211211- } else {
212212- self.query_actors_with_cursor(slice_uri, cursor, limit)
213213- .await?
214214- };
146146+ }
215147216216- let cursor = if records.is_empty() {
217217- None
148148+ // Bind cursor parameter
149149+ if let Some(cursor_did) = cursor {
150150+ sqlx_query = sqlx_query.bind(cursor_did);
151151+ }
152152+153153+ // Bind limit
154154+ sqlx_query = sqlx_query.bind(limit as i64);
155155+156156+ let records = sqlx_query.fetch_all(&self.pool).await?;
157157+158158+ let cursor = if records.len() < limit as usize {
159159+ None // Last page - no more results
218160 } else {
219161 records.last().map(|actor| actor.did.clone())
220162 };
···222164 Ok((records, cursor))
223165 }
224166225225- /// Internal helper for basic actor queries with cursor pagination.
226226- async fn query_actors_with_cursor(
227227- &self,
228228- slice_uri: &str,
229229- cursor: Option<&str>,
230230- limit: i32,
231231- ) -> Result<Vec<Actor>, DatabaseError> {
232232- match cursor {
233233- Some(cursor_did) => sqlx::query_as!(
234234- Actor,
235235- r#"
236236- SELECT did, handle, slice_uri, indexed_at
237237- FROM actor
238238- WHERE slice_uri = $1 AND did > $2
239239- ORDER BY did ASC
240240- LIMIT $3
241241- "#,
242242- slice_uri,
243243- cursor_did,
244244- limit as i64
245245- )
246246- .fetch_all(&self.pool)
247247- .await
248248- .map_err(DatabaseError::from),
249249- None => sqlx::query_as!(
250250- Actor,
251251- r#"
252252- SELECT did, handle, slice_uri, indexed_at
253253- FROM actor
254254- WHERE slice_uri = $1
255255- ORDER BY did ASC
256256- LIMIT $2
257257- "#,
258258- slice_uri,
259259- limit as i64
260260- )
261261- .fetch_all(&self.pool)
262262- .await
263263- .map_err(DatabaseError::from),
264264- }
265265- }
266167267168 /// Gets all actors across all slices.
268169 ///
···324225 Ok(result.rows_affected())
325226 }
326227}
228228+229229+/// Builds WHERE conditions specifically for actor queries.
230230+///
231231+/// Unlike the general query builder, this handles actor table columns directly
232232+/// rather than treating them as JSON paths.
233233+fn build_actor_where_conditions(
234234+ where_clause: Option<&WhereClause>,
235235+ param_count: &mut usize,
236236+) -> (Vec<String>, Vec<String>) {
237237+ let mut where_clauses = Vec::new();
238238+ let mut or_clauses = Vec::new();
239239+240240+ if let Some(clause) = where_clause {
241241+ for (field, condition) in &clause.conditions {
242242+ let field_clause = build_actor_single_condition(field, condition, param_count);
243243+ if !field_clause.is_empty() {
244244+ where_clauses.push(field_clause);
245245+ }
246246+ }
247247+248248+ if let Some(or_conditions) = &clause.or_conditions {
249249+ for (field, condition) in or_conditions {
250250+ let field_clause = build_actor_single_condition(field, condition, param_count);
251251+ if !field_clause.is_empty() {
252252+ or_clauses.push(field_clause);
253253+ }
254254+ }
255255+ }
256256+ }
257257+258258+ (where_clauses, or_clauses)
259259+}
260260+261261+/// Builds a single SQL condition clause for actor fields.
262262+fn build_actor_single_condition(
263263+ field: &str,
264264+ condition: &WhereCondition,
265265+ param_count: &mut usize,
266266+) -> String {
267267+ if let Some(_eq_value) = &condition.eq {
268268+ let clause = format!("{} = ${}", field, param_count);
269269+ *param_count += 1;
270270+ clause
271271+ } else if let Some(_in_values) = &condition.in_values {
272272+ let clause = format!("{} = ANY(${})", field, param_count);
273273+ *param_count += 1;
274274+ clause
275275+ } else if let Some(_contains_value) = &condition.contains {
276276+ let clause = format!("{} ILIKE '%' || ${} || '%'", field, param_count);
277277+ *param_count += 1;
278278+ clause
279279+ } else {
280280+ String::new()
281281+ }
282282+}
+3-2
api/src/database/records.rs
···342342343343 let records = query_builder.fetch_all(&self.pool).await?;
344344345345- let cursor = if records.is_empty() {
346346- None
345345+ // Only return cursor if we got a full page, indicating there might be more
346346+ let cursor = if records.len() < limit as usize {
347347+ None // Last page - no more results
347348 } else {
348349 records
349350 .last()
···11// Generated TypeScript client for AT Protocol records
22-// Generated at: 2025-09-26 18:40:59 UTC
22+// Generated at: 2025-09-28 00:41:14 UTC
33// Lexicons: 40
4455/**
···984984 indexedActorCount?: number;
985985 /** Number of collections with indexed records */
986986 indexedCollectionCount?: number;
987987+ /** Total number of waitlist requests for this slice */
988988+ waitlistRequestCount?: number;
989989+ /** Total number of waitlist invites for this slice */
990990+ waitlistInviteCount?: number;
987991}
988992989993export interface NetworkSlicesSliceDefsSparklinePoint {
···5050 "indexedCollectionCount": {
5151 "type": "integer",
5252 "description": "Number of collections with indexed records"
5353+ },
5454+ "waitlistRequestCount": {
5555+ "type": "integer",
5656+ "description": "Total number of waitlist requests for this slice"
5757+ },
5858+ "waitlistInviteCount": {
5959+ "type": "integer",
6060+ "description": "Total number of waitlist invites for this slice"
5361 }
5462 }
5563 },
+5-1
packages/cli/src/generated_client.ts
···11// Generated TypeScript client for AT Protocol records
22-// Generated at: 2025-09-26 18:21:58 UTC
22+// Generated at: 2025-09-28 00:41:51 UTC
33// Lexicons: 40
4455/**
···984984 indexedActorCount?: number;
985985 /** Number of collections with indexed records */
986986 indexedCollectionCount?: number;
987987+ /** Total number of waitlist requests for this slice */
988988+ waitlistRequestCount?: number;
989989+ /** Total number of waitlist invites for this slice */
990990+ waitlistInviteCount?: number;
987991}
988992989993export interface NetworkSlicesSliceDefsSparklinePoint {