Select the types of activity you want to include in your feed.
add type-safe sort params based on sortable lexicon fields, wire up listRecords and searchRecords to use new sort param, actually return a cursor now base64 encoded
···3434# UUID generation
3535uuid = { version = "1.0", features = ["v4", "serde"] }
36363737+# Base64 encoding for cursors
3838+base64 = "0.22"
3939+3740# Environment variables
3841dotenvy = "0.15"
3942···4548atproto-identity = "0.11.2"
4649atproto-oauth = "0.11.2"
47504848-# Base64 encoding/decoding
4949-base64 = "0.22"
50515152# Middleware for HTTP requests with retry logic
5253reqwest-middleware = { version = "0.4.2", features = ["json", "multipart"] }
+4-4
api/scripts/codegen.sh
···11#!/bin/bash
2233# Collect all lexicon files and format them for the TypeScript generator
44-echo "Collecting lexicons from ./lexicons..."
44+echo "Collecting lexicons from ../lexicons..."
5566# Create a temporary JSON array of lexicons
77temp_file=$(mktemp)
88echo "[" > "$temp_file"
991010first=true
1111-for lexicon_file in $(find ./lexicons -name "*.json" -type f); do
1111+for lexicon_file in $(find ../lexicons -name "*.json" -type f); do
1212 if [ "$first" = false ]; then
1313 echo "," >> "$temp_file"
1414 fi
···32323333# Generate the TypeScript client
3434echo "Generating TypeScript client..."
3535-deno run --allow-all api/scripts/generate-typescript.ts "$(cat "$temp_file")" > api/generated_client.ts
3535+deno run --allow-all ./scripts/generate-typescript.ts "$(cat "$temp_file")" > ./generated_client.ts
36363737# Clean up
3838rm "$temp_file"
39394040-echo "✅ Generated TypeScript client at api/generated_client.ts"4040+echo "✅ Generated TypeScript client at ./generated_client.ts"
···11use sqlx::PgPool;
22+use base64::{Engine as _, engine::general_purpose};
2334use crate::errors::DatabaseError;
45use crate::models::{Actor, CollectionStats, IndexedRecord, Record};
5677+// Helper function to get field type from lexicon definition
88+async fn get_field_type_from_lexicon(
99+ pool: &sqlx::PgPool,
1010+ slice_uri: &str,
1111+ collection: &str,
1212+ field: &str
1313+) -> Option<String> {
1414+ let lexicon_query = sqlx::query!(
1515+ r#"
1616+ SELECT json->>'definitions' as definitions
1717+ FROM record
1818+ WHERE collection = 'social.slices.lexicon'
1919+ AND json->>'slice' = $1
2020+ AND json->>'nsid' = $2
2121+ AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'
2222+ LIMIT 1
2323+ "#,
2424+ slice_uri,
2525+ collection
2626+ );
2727+2828+ if let Ok(Some(row)) = lexicon_query.fetch_optional(pool).await {
2929+ if let Some(definitions_str) = &row.definitions {
3030+ if let Ok(definitions) = serde_json::from_str::<serde_json::Value>(definitions_str) {
3131+ let field_def = definitions
3232+ .get("main")?
3333+ .get("record")?
3434+ .get("properties")?
3535+ .get(field)?;
3636+3737+ // Check if it's a datetime field
3838+ if let Some(field_type) = field_def.get("type").and_then(|t| t.as_str()) {
3939+ if field_type == "string" {
4040+ if let Some(format) = field_def.get("format").and_then(|f| f.as_str()) {
4141+ if format == "datetime" {
4242+ return Some("datetime".to_string());
4343+ }
4444+ }
4545+ }
4646+ return Some(field_type.to_string());
4747+ }
4848+ }
4949+ }
5050+ }
5151+ None
5252+}
5353+5454+// Async helper function to parse sort parameter with lexicon type information
5555+async fn parse_sort_parameter_with_lexicon(
5656+ pool: &sqlx::PgPool,
5757+ slice_uri: &str,
5858+ collection: &str,
5959+ sort: Option<&str>
6060+) -> String {
6161+ match sort {
6262+ Some(sort_str) => {
6363+ let mut order_clauses = Vec::new();
6464+ for sort_item in sort_str.split(',') {
6565+ let parts: Vec<&str> = sort_item.trim().split(':').collect();
6666+ if parts.len() == 2 {
6767+ let field = parts[0].trim();
6868+ let direction = match parts[1].trim().to_lowercase().as_str() {
6969+ "desc" => "DESC",
7070+ _ => "ASC", // Default to ASC for any invalid direction
7171+ };
7272+7373+ // Validate field name to prevent SQL injection
7474+ if field.chars().all(|c| c.is_alphanumeric() || c == '_') {
7575+ if field == "indexed_at" {
7676+ order_clauses.push(format!("{field} {direction}"));
7777+ } else {
7878+ // Get field type from lexicon
7979+ let field_type = get_field_type_from_lexicon(pool, slice_uri, collection, field).await;
8080+8181+ if field_type == Some("datetime".to_string()) {
8282+ // For datetime fields, use safe casting that handles invalid dates
8383+ // This will cast valid dates and return NULL for invalid ones
8484+ order_clauses.push(format!("(CASE WHEN json->>'{field}' ~ '^\\d{{4}}-\\d{{2}}-\\d{{2}}T\\d{{2}}:\\d{{2}}:\\d{{2}}' THEN (json->>'{field}')::timestamptz ELSE NULL END) {direction} NULLS LAST"));
8585+ } else {
8686+ // For other JSON fields, handle NULLs properly with text sorting
8787+ order_clauses.push(format!("json->>'{field}' {direction} NULLS LAST"));
8888+ }
8989+ }
9090+ }
9191+ }
9292+ }
9393+ if !order_clauses.is_empty() {
9494+ // Always add indexed_at as tie-breaker if not already included
9595+ let has_indexed_at = order_clauses.iter().any(|clause| clause.contains("indexed_at"));
9696+ if !has_indexed_at {
9797+ order_clauses.push("indexed_at DESC".to_string());
9898+ }
9999+ order_clauses.join(", ")
100100+ } else {
101101+ "indexed_at DESC".to_string() // Default sort
102102+ }
103103+ }
104104+ None => "indexed_at DESC".to_string() // Default sort
105105+ }
106106+}
107107+108108+// Helper function to parse sort parameter and build ORDER BY clause (fallback without lexicon)
109109+fn parse_sort_parameter(sort: Option<&str>) -> String {
110110+ match sort {
111111+ Some(sort_str) => {
112112+ let mut order_clauses = Vec::new();
113113+ for sort_item in sort_str.split(',') {
114114+ let parts: Vec<&str> = sort_item.trim().split(':').collect();
115115+ if parts.len() == 2 {
116116+ let field = parts[0].trim();
117117+ let direction = match parts[1].trim().to_lowercase().as_str() {
118118+ "desc" => "DESC",
119119+ _ => "ASC", // Default to ASC for any invalid direction
120120+ };
121121+122122+ // Validate field name to prevent SQL injection
123123+ if field.chars().all(|c| c.is_alphanumeric() || c == '_') {
124124+ if field == "indexed_at" {
125125+ order_clauses.push(format!("{field} {direction}"));
126126+ } else {
127127+ // For JSON fields, handle NULLs properly with text sorting
128128+ // (No lexicon lookup in this fallback function)
129129+ order_clauses.push(format!("json->>'{field}' {direction} NULLS LAST"));
130130+ }
131131+ }
132132+ }
133133+ }
134134+ if !order_clauses.is_empty() {
135135+ // Always add indexed_at as tie-breaker if not already included
136136+ let has_indexed_at = order_clauses.iter().any(|clause| clause.contains("indexed_at"));
137137+ if !has_indexed_at {
138138+ order_clauses.push("indexed_at DESC".to_string());
139139+ }
140140+ order_clauses.join(", ")
141141+ } else {
142142+ "indexed_at DESC".to_string() // Default sort
143143+ }
144144+ }
145145+ None => "indexed_at DESC".to_string() // Default sort
146146+ }
147147+}
148148+149149+// Cursor utilities for ATProto-style pagination
150150+#[derive(Debug)]
151151+struct ParsedCursor {
152152+ sort_value: String,
153153+ indexed_at: chrono::DateTime<chrono::Utc>,
154154+ cid: String,
155155+}
156156+157157+fn parse_cursor(cursor: &str) -> Result<ParsedCursor, Box<dyn std::error::Error + Send + Sync>> {
158158+ // First try to decode from base64
159159+ let cursor_content = if let Ok(decoded) = general_purpose::URL_SAFE_NO_PAD.decode(cursor) {
160160+ String::from_utf8(decoded)?
161161+ } else {
162162+ // Fallback to plain text for backward compatibility
163163+ cursor.to_string()
164164+ };
165165+166166+ let parts: Vec<&str> = cursor_content.split("::").collect();
167167+ if parts.len() != 3 {
168168+ return Err("Invalid cursor format".into());
169169+ }
170170+171171+ let sort_value = parts[0].to_string();
172172+ let indexed_at = parts[1].parse::<chrono::DateTime<chrono::Utc>>()?;
173173+ let cid = parts[2].to_string();
174174+175175+ Ok(ParsedCursor {
176176+ sort_value,
177177+ indexed_at,
178178+ cid,
179179+ })
180180+}
181181+182182+fn generate_cursor(sort_value: &str, indexed_at: chrono::DateTime<chrono::Utc>, cid: &str) -> String {
183183+ let cursor_content = format!("{}::{}::{}", sort_value, indexed_at.to_rfc3339(), cid);
184184+ general_purpose::URL_SAFE_NO_PAD.encode(cursor_content)
185185+}
186186+187187+// Extract the primary sort field from sort parameter for cursor generation
188188+fn get_primary_sort_field(sort: Option<&str>) -> String {
189189+ match sort {
190190+ Some(sort_str) => {
191191+ // Get the first sort field (primary)
192192+ let first_sort = sort_str.split(',').next().unwrap_or("indexed_at");
193193+ let parts: Vec<&str> = first_sort.trim().split(':').collect();
194194+ parts[0].trim().to_string()
195195+ }
196196+ None => "indexed_at".to_string(),
197197+ }
198198+}
199199+200200+// Check if the primary sort field is descending
201201+fn is_primary_sort_desc(sort: Option<&str>) -> bool {
202202+ match sort {
203203+ Some(sort_str) => {
204204+ let first_sort = sort_str.split(',').next().unwrap_or("indexed_at:desc");
205205+ let parts: Vec<&str> = first_sort.trim().split(':').collect();
206206+ if parts.len() > 1 {
207207+ parts[1].trim().to_lowercase() == "desc"
208208+ } else {
209209+ true // Default to DESC
210210+ }
211211+ }
212212+ None => true, // Default sort is DESC
213213+ }
214214+}
215215+216216+// Build cursor WHERE clause for compound cursor filtering
217217+fn build_cursor_where_clause(_parsed_cursor: &ParsedCursor, sort_field: &str, is_desc: bool) -> String {
218218+ // For compound cursor filtering, we use tuple comparison:
219219+ // WHERE (sort_field, indexed_at, cid) < (cursor_sort, cursor_indexed_at, cursor_cid) for DESC
220220+ // WHERE (sort_field, indexed_at, cid) > (cursor_sort, cursor_indexed_at, cursor_cid) for ASC
221221+222222+ let comparison_op = if is_desc { "<" } else { ">" };
223223+224224+ // Handle different field types for the sort field comparison
225225+ let sort_field_expr = if sort_field == "indexed_at" {
226226+ sort_field.to_string()
227227+ } else {
228228+ // For JSON fields, cast to text for comparison
229229+ format!("json->>'{}'", sort_field)
230230+ };
231231+232232+ // Handle NULL values in cursor comparison
233233+ if comparison_op == "<" {
234234+ // For DESC ordering, we want records where:
235235+ // 1. sort_field is NULL and cursor is not NULL (NULLs come first in DESC)
236236+ // 2. OR both are not NULL and tuple comparison
237237+ format!(
238238+ "AND (({} IS NULL AND $1 != 'NULL') OR ({} IS NOT NULL AND $1 != 'NULL' AND ({}, indexed_at, cid) {} ($1, $2::timestamptz, $3)))",
239239+ sort_field_expr, sort_field_expr, sort_field_expr, comparison_op
240240+ )
241241+ } else {
242242+ // For ASC ordering, standard tuple comparison works better
243243+ format!(
244244+ "AND ({}, indexed_at, cid) {} ($1, $2::timestamptz, $3)",
245245+ sort_field_expr, comparison_op
246246+ )
247247+ }
248248+}
249249+250250+// Generate cursor from record and sort parameters
251251+fn generate_cursor_from_record(record: &Record, sort: Option<&str>) -> String {
252252+ let primary_sort_field = get_primary_sort_field(sort);
253253+254254+ // Extract sort value from the record based on the sort field
255255+ let sort_value = match primary_sort_field.as_str() {
256256+ "indexed_at" => record.indexed_at.to_rfc3339(),
257257+ field => {
258258+ // Extract field value from JSON
259259+ record.json.get(field)
260260+ .and_then(|v| match v {
261261+ serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
262262+ serde_json::Value::Number(n) => Some(n.to_string()),
263263+ serde_json::Value::Bool(b) => Some(b.to_string()),
264264+ serde_json::Value::Null => None, // Explicitly handle null
265265+ _ => None,
266266+ })
267267+ .unwrap_or_else(|| "NULL".to_string()) // Use "NULL" string for null values to match SQL NULLS LAST behavior
268268+ }
269269+ };
270270+271271+ generate_cursor(&sort_value, record.indexed_at, &record.cid)
272272+}
273273+6274#[derive(Clone)]
7275pub struct Database {
8276 pool: PgPool,
···324592 query: &str,
325593 field: Option<&str>,
326594 limit: Option<i32>,
327327- cursor: Option<&str>
328328- ) -> Result<Vec<Record>, DatabaseError> {
595595+ cursor: Option<&str>,
596596+ sort: Option<&str>
597597+ ) -> Result<(Vec<Record>, Option<String>), DatabaseError> {
329598 let limit = limit.unwrap_or(50).min(100); // Cap at 100
330599331600 // For lexicon collection, we filter by slice directly
332601 if collection == "social.slices.lexicon" {
602602+ let order_by = parse_sort_parameter(sort); // Use simple parsing for lexicon collections
333603 let records = match (cursor, field) {
334334- (Some(cursor_time), Some(field_name)) => {
335335- let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
336336- .unwrap_or_else(|_| chrono::Utc::now());
337337- sqlx::query_as!(
338338- Record,
339339- r#"
340340- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
341341- FROM record
342342- WHERE collection = $1 AND json->>'slice' = $2
343343- AND json->>$3 ILIKE '%' || $4 || '%'
344344- AND "indexed_at" < $5
345345- ORDER BY "indexed_at" DESC
346346- LIMIT $6
347347- "#,
348348- collection,
349349- slice_uri,
350350- field_name,
351351- query,
352352- cursor_dt,
353353- limit as i64
354354- )
355355- .fetch_all(&self.pool)
356356- .await?
604604+ (Some(cursor_str), Some(field_name)) => {
605605+ // Try to parse compound cursor, fallback to old cursor format
606606+ if let Ok(parsed_cursor) = parse_cursor(cursor_str) {
607607+ let primary_sort_field = get_primary_sort_field(sort);
608608+ let is_desc = is_primary_sort_desc(sort);
609609+ let cursor_where_clause = build_cursor_where_clause(&parsed_cursor, &primary_sort_field, is_desc);
610610+611611+ let query_sql = format!(
612612+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $4 AND json->>'slice' = $5 AND json->>$6 ILIKE '%' || $7 || '%' {} ORDER BY {} LIMIT $8",
613613+ cursor_where_clause, order_by
614614+ );
615615+ sqlx::query_as::<_, Record>(&query_sql)
616616+ .bind(&parsed_cursor.sort_value) // $1
617617+ .bind(&parsed_cursor.indexed_at) // $2
618618+ .bind(&parsed_cursor.cid) // $3
619619+ .bind(collection) // $4
620620+ .bind(slice_uri) // $5
621621+ .bind(field_name) // $6
622622+ .bind(query) // $7
623623+ .bind(limit as i64) // $8
624624+ .fetch_all(&self.pool)
625625+ .await?
626626+ } else {
627627+ // Fallback to old cursor format (indexed_at only)
628628+ let cursor_dt = cursor_str.parse::<chrono::DateTime<chrono::Utc>>()
629629+ .unwrap_or_else(|_| chrono::Utc::now());
630630+ let query_sql = format!(
631631+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>'slice' = $2 AND json->>$3 ILIKE '%' || $4 || '%' AND indexed_at < $5 ORDER BY {} LIMIT $6",
632632+ order_by
633633+ );
634634+ sqlx::query_as::<_, Record>(&query_sql)
635635+ .bind(collection)
636636+ .bind(slice_uri)
637637+ .bind(field_name)
638638+ .bind(query)
639639+ .bind(cursor_dt)
640640+ .bind(limit as i64)
641641+ .fetch_all(&self.pool)
642642+ .await?
643643+ }
357644 },
358645 (Some(cursor_time), None) => {
359646 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
360647 .unwrap_or_else(|_| chrono::Utc::now());
361361- sqlx::query_as!(
362362- Record,
363363- r#"
364364- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
365365- FROM record
366366- WHERE collection = $1 AND json->>'slice' = $2
367367- AND json::text ILIKE '%' || $3 || '%'
368368- AND "indexed_at" < $4
369369- ORDER BY "indexed_at" DESC
370370- LIMIT $5
371371- "#,
372372- collection,
373373- slice_uri,
374374- query,
375375- cursor_dt,
376376- limit as i64
377377- )
378378- .fetch_all(&self.pool)
379379- .await?
648648+ let query_sql = format!(
649649+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>'slice' = $2 AND json::text ILIKE '%' || $3 || '%' AND indexed_at < $4 ORDER BY {} LIMIT $5",
650650+ order_by
651651+ );
652652+ sqlx::query_as::<_, Record>(&query_sql)
653653+ .bind(collection)
654654+ .bind(slice_uri)
655655+ .bind(query)
656656+ .bind(cursor_dt)
657657+ .bind(limit as i64)
658658+ .fetch_all(&self.pool)
659659+ .await?
380660 },
381661 (None, Some(field_name)) => {
382382- sqlx::query_as!(
383383- Record,
384384- r#"
385385- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
386386- FROM record
387387- WHERE collection = $1 AND json->>'slice' = $2
388388- AND json->>$3 ILIKE '%' || $4 || '%'
389389- ORDER BY "indexed_at" DESC
390390- LIMIT $5
391391- "#,
392392- collection,
393393- slice_uri,
394394- field_name,
395395- query,
396396- limit as i64
397397- )
398398- .fetch_all(&self.pool)
399399- .await?
662662+ let query_sql = format!(
663663+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>'slice' = $2 AND json->>$3 ILIKE '%' || $4 || '%' ORDER BY {} LIMIT $5",
664664+ order_by
665665+ );
666666+ sqlx::query_as::<_, Record>(&query_sql)
667667+ .bind(collection)
668668+ .bind(slice_uri)
669669+ .bind(field_name)
670670+ .bind(query)
671671+ .bind(limit as i64)
672672+ .fetch_all(&self.pool)
673673+ .await?
400674 },
401675 (None, None) => {
402402- sqlx::query_as!(
403403- Record,
404404- r#"
405405- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
406406- FROM record
407407- WHERE collection = $1 AND json->>'slice' = $2
408408- AND json::text ILIKE '%' || $3 || '%'
409409- ORDER BY "indexed_at" DESC
410410- LIMIT $4
411411- "#,
412412- collection,
413413- slice_uri,
414414- query,
415415- limit as i64
416416- )
417417- .fetch_all(&self.pool)
418418- .await?
676676+ let query_sql = format!(
677677+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>'slice' = $2 AND json::text ILIKE '%' || $3 || '%' ORDER BY {} LIMIT $4",
678678+ order_by
679679+ );
680680+ sqlx::query_as::<_, Record>(&query_sql)
681681+ .bind(collection)
682682+ .bind(slice_uri)
683683+ .bind(query)
684684+ .bind(limit as i64)
685685+ .fetch_all(&self.pool)
686686+ .await?
419687 },
420688 };
421421- return Ok(records);
689689+ let cursor = if records.is_empty() {
690690+ None
691691+ } else {
692692+ records.last().map(|record| generate_cursor_from_record(record, sort))
693693+ };
694694+ return Ok((records, cursor));
422695 } else {
423696 // For other collections, verify the collection belongs to this slice's lexicons
424697 let collection_exists = sqlx::query!(
···438711439712 if collection_exists.is_none() {
440713 // Collection not found in this slice's lexicons
441441- return Ok(vec![]);
714714+ return Ok((vec![], None));
442715 }
443716 }
717717+718718+ // Get lexicon-aware ORDER BY clause for non-lexicon collections
719719+ let order_by = parse_sort_parameter_with_lexicon(&self.pool, slice_uri, collection, sort).await;
444720445721 // Now search the records with cursor-based pagination
446722 let records = match (cursor, field) {
447723 (Some(cursor_time), Some(field_name)) => {
448724 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
449725 .unwrap_or_else(|_| chrono::Utc::now());
450450- sqlx::query_as!(
451451- Record,
452452- r#"
453453- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
454454- FROM record
455455- WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%' AND "indexed_at" < $4
456456- ORDER BY "indexed_at" DESC
457457- LIMIT $5
458458- "#,
459459- collection,
460460- field_name,
461461- query,
462462- cursor_dt,
463463- limit as i64
464464- )
465465- .fetch_all(&self.pool)
466466- .await?
726726+ let query_sql = format!(
727727+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%' AND indexed_at < $4 ORDER BY {} LIMIT $5",
728728+ order_by
729729+ );
730730+ sqlx::query_as::<_, Record>(&query_sql)
731731+ .bind(collection)
732732+ .bind(field_name)
733733+ .bind(query)
734734+ .bind(cursor_dt)
735735+ .bind(limit as i64)
736736+ .fetch_all(&self.pool)
737737+ .await?
467738 },
468739 (Some(cursor_time), None) => {
469740 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
470741 .unwrap_or_else(|_| chrono::Utc::now());
471471- sqlx::query_as!(
472472- Record,
473473- r#"
474474- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
475475- FROM record
476476- WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%' AND "indexed_at" < $3
477477- ORDER BY "indexed_at" DESC
478478- LIMIT $4
479479- "#,
480480- collection,
481481- query,
482482- cursor_dt,
483483- limit as i64
484484- )
485485- .fetch_all(&self.pool)
486486- .await?
742742+ let query_sql = format!(
743743+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%' AND indexed_at < $3 ORDER BY {} LIMIT $4",
744744+ order_by
745745+ );
746746+ sqlx::query_as::<_, Record>(&query_sql)
747747+ .bind(collection)
748748+ .bind(query)
749749+ .bind(cursor_dt)
750750+ .bind(limit as i64)
751751+ .fetch_all(&self.pool)
752752+ .await?
487753 },
488754 (None, Some(field_name)) => {
489489- sqlx::query_as!(
490490- Record,
491491- r#"
492492- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
493493- FROM record
494494- WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%'
495495- ORDER BY "indexed_at" DESC
496496- LIMIT $4
497497- "#,
498498- collection,
499499- field_name,
500500- query,
501501- limit as i64
502502- )
503503- .fetch_all(&self.pool)
504504- .await?
755755+ let query_sql = format!(
756756+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%' ORDER BY {} LIMIT $4",
757757+ order_by
758758+ );
759759+ sqlx::query_as::<_, Record>(&query_sql)
760760+ .bind(collection)
761761+ .bind(field_name)
762762+ .bind(query)
763763+ .bind(limit as i64)
764764+ .fetch_all(&self.pool)
765765+ .await?
505766 },
506767 (None, None) => {
507507- sqlx::query_as!(
508508- Record,
509509- r#"
510510- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
511511- FROM record
512512- WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%'
513513- ORDER BY "indexed_at" DESC
514514- LIMIT $3
515515- "#,
516516- collection,
517517- query,
518518- limit as i64
519519- )
520520- .fetch_all(&self.pool)
521521- .await?
768768+ let query_sql = format!(
769769+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%' ORDER BY {} LIMIT $3",
770770+ order_by
771771+ );
772772+ sqlx::query_as::<_, Record>(&query_sql)
773773+ .bind(collection)
774774+ .bind(query)
775775+ .bind(limit as i64)
776776+ .fetch_all(&self.pool)
777777+ .await?
522778 },
523779 };
524780525525- Ok(records)
781781+ // Generate cursor from the last record if there are any records
782782+ let cursor = if records.is_empty() {
783783+ None
784784+ } else {
785785+ records.last().map(|record| generate_cursor_from_record(record, sort))
786786+ };
787787+788788+ Ok((records, cursor))
526789 }
527790528791 pub async fn get_slice_collection_records(
···531794 collection: &str,
532795 repo: Option<&str>,
533796 limit: Option<i32>,
534534- cursor: Option<&str>
535535- ) -> Result<Vec<Record>, DatabaseError> {
797797+ cursor: Option<&str>,
798798+ sort: Option<&str>
799799+ ) -> Result<(Vec<Record>, Option<String>), DatabaseError> {
536800 let limit = limit.unwrap_or(50).min(100); // Cap at 100
537801538802 // For lexicon collection, we filter by slice directly
539803 if collection == "social.slices.lexicon" {
804804+ let order_by = parse_sort_parameter(sort); // Use simple parsing for lexicon collections
540805 // For lexicon records, filter by slice
541806 let records = match (cursor, repo) {
542807 (Some(cursor_time), Some(repo_did)) => {
···581846 .await?
582847 },
583848 (None, Some(repo_did)) => {
584584- sqlx::query_as!(
585585- Record,
849849+ let sql = format!(
586850 r#"
587587- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
851851+ SELECT uri, cid, did, collection, json, indexed_at
588852 FROM record
589853 WHERE collection = $1 AND json->>'slice' = $2 AND did = $3
590590- ORDER BY "indexed_at" DESC
854854+ ORDER BY {}
591855 LIMIT $4
592856 "#,
593593- collection,
594594- slice_uri,
595595- repo_did,
596596- limit as i64
597597- )
598598- .fetch_all(&self.pool)
599599- .await?
857857+ order_by
858858+ );
859859+ sqlx::query_as::<_, Record>(&sql)
860860+ .bind(collection)
861861+ .bind(slice_uri)
862862+ .bind(repo_did)
863863+ .bind(limit as i64)
864864+ .fetch_all(&self.pool)
865865+ .await?
600866 },
601867 (None, None) => {
602602- sqlx::query_as!(
603603- Record,
868868+ let sql = format!(
604869 r#"
605605- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
870870+ SELECT uri, cid, did, collection, json, indexed_at
606871 FROM record
607872 WHERE collection = $1 AND json->>'slice' = $2
608608- ORDER BY "indexed_at" DESC
873873+ ORDER BY {}
609874 LIMIT $3
610875 "#,
611611- collection,
612612- slice_uri,
613613- limit as i64
614614- )
615615- .fetch_all(&self.pool)
616616- .await?
876876+ order_by
877877+ );
878878+ sqlx::query_as::<_, Record>(&sql)
879879+ .bind(collection)
880880+ .bind(slice_uri)
881881+ .bind(limit as i64)
882882+ .fetch_all(&self.pool)
883883+ .await?
617884 },
618885 };
619619- return Ok(records);
886886+ let cursor = if records.is_empty() {
887887+ None
888888+ } else {
889889+ records.last().map(|record| generate_cursor_from_record(record, sort))
890890+ };
891891+ return Ok((records, cursor));
620892 } else {
621893 // For other collections, verify the collection belongs to this slice's lexicons
622894 let collection_exists = sqlx::query!(
···636908637909 if collection_exists.is_none() {
638910 // Collection not found in this slice's lexicons
639639- return Ok(vec![]);
911911+ return Ok((vec![], None));
640912 }
641913 }
642914915915+ // Get lexicon-aware ORDER BY clause for non-lexicon collections
916916+ let order_by = parse_sort_parameter_with_lexicon(&self.pool, slice_uri, collection, sort).await;
917917+643918 // Now fetch the records with cursor-based pagination
644919 let records = match (cursor, repo) {
645920 (Some(cursor_time), Some(repo_did)) => {
646921 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
647922 .unwrap_or_else(|_| chrono::Utc::now());
648648- sqlx::query_as!(
649649- Record,
650650- r#"
651651- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
652652- FROM record
653653- WHERE collection = $1 AND "indexed_at" < $2 AND did = $3
654654- ORDER BY "indexed_at" DESC
655655- LIMIT $4
656656- "#,
657657- collection,
658658- cursor_dt,
659659- repo_did,
660660- limit as i64
661661- )
662662- .fetch_all(&self.pool)
663663- .await?
923923+ let query = format!(
924924+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND indexed_at < $2 AND did = $3 ORDER BY {} LIMIT $4",
925925+ order_by
926926+ );
927927+ sqlx::query_as::<_, Record>(&query)
928928+ .bind(collection)
929929+ .bind(cursor_dt)
930930+ .bind(repo_did)
931931+ .bind(limit as i64)
932932+ .fetch_all(&self.pool)
933933+ .await?
664934 },
665935 (Some(cursor_time), None) => {
666936 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
667937 .unwrap_or_else(|_| chrono::Utc::now());
668668- sqlx::query_as!(
669669- Record,
670670- r#"
671671- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
672672- FROM record
673673- WHERE collection = $1 AND "indexed_at" < $2
674674- ORDER BY "indexed_at" DESC
675675- LIMIT $3
676676- "#,
677677- collection,
678678- cursor_dt,
679679- limit as i64
680680- )
681681- .fetch_all(&self.pool)
682682- .await?
938938+ let query = format!(
939939+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND indexed_at < $2 ORDER BY {} LIMIT $3",
940940+ order_by
941941+ );
942942+ sqlx::query_as::<_, Record>(&query)
943943+ .bind(collection)
944944+ .bind(cursor_dt)
945945+ .bind(limit as i64)
946946+ .fetch_all(&self.pool)
947947+ .await?
683948 },
684949 (None, Some(repo_did)) => {
685685- sqlx::query_as!(
686686- Record,
687687- r#"
688688- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
689689- FROM record
690690- WHERE collection = $1 AND did = $2
691691- ORDER BY "indexed_at" DESC
692692- LIMIT $3
693693- "#,
694694- collection,
695695- repo_did,
696696- limit as i64
697697- )
698698- .fetch_all(&self.pool)
699699- .await?
950950+ let query = format!(
951951+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 AND did = $2 ORDER BY {} LIMIT $3",
952952+ order_by
953953+ );
954954+ sqlx::query_as::<_, Record>(&query)
955955+ .bind(collection)
956956+ .bind(repo_did)
957957+ .bind(limit as i64)
958958+ .fetch_all(&self.pool)
959959+ .await?
700960 },
701961 (None, None) => {
702702- sqlx::query_as!(
703703- Record,
704704- r#"
705705- SELECT uri, cid, did, collection, json, "indexed_at" as indexed_at
706706- FROM record
707707- WHERE collection = $1
708708- ORDER BY "indexed_at" DESC
709709- LIMIT $2
710710- "#,
711711- collection,
712712- limit as i64
713713- )
714714- .fetch_all(&self.pool)
715715- .await?
962962+ let query = format!(
963963+ "SELECT uri, cid, did, collection, json, indexed_at FROM record WHERE collection = $1 ORDER BY {} LIMIT $2",
964964+ order_by
965965+ );
966966+ sqlx::query_as::<_, Record>(&query)
967967+ .bind(collection)
968968+ .bind(limit as i64)
969969+ .fetch_all(&self.pool)
970970+ .await?
716971 },
717972 };
718973719719- Ok(records)
974974+ // Generate cursor from the last record if there are any records
975975+ let cursor = if records.is_empty() {
976976+ None
977977+ } else {
978978+ records.last().map(|record| generate_cursor_from_record(record, sort))
979979+ };
980980+981981+ Ok((records, cursor))
720982 }
721983722984}
+3-3
api/src/handler_records.rs
···2222}
23232424async fn get_slice_collection_records(state: &AppState, params: &SliceRecordsParams) -> Result<SliceRecordsOutput, Box<dyn std::error::Error + Send + Sync>> {
2525- let records = state.database.get_slice_collection_records(
2525+ let (records, cursor) = state.database.get_slice_collection_records(
2626 ¶ms.slice,
2727 ¶ms.collection,
2828 params.repo.as_deref(),
2929 params.limit,
3030 params.cursor.as_deref(),
3131+ None, // No sort parameter for this endpoint yet
3132 ).await?;
32333334 // Transform Record to IndexedRecord for the response
···4041 indexed_at: record.indexed_at.to_rfc3339(),
4142 }).collect();
42434343- // Use the last record's indexed_at as cursor for pagination
4444- let cursor = indexed_records.last().map(|r| r.indexed_at.clone());
4444+ // Cursor is now generated by the database layer
45454646 Ok(SliceRecordsOutput {
4747 success: true,