···11+/// GraphQL Connection Types for Relay Cursor Connections
22+///
33+/// Implements the Relay Cursor Connections Specification:
44+/// https://relay.dev/graphql/connections.htm
55+import gleam/list
66+import gleam/option.{type Option, None, Some}
77+import graphql/schema
88+import graphql/value
99+1010+/// PageInfo type for connection pagination metadata
1111+pub type PageInfo {
1212+ PageInfo(
1313+ has_next_page: Bool,
1414+ has_previous_page: Bool,
1515+ start_cursor: Option(String),
1616+ end_cursor: Option(String),
1717+ )
1818+}
1919+2020+/// Edge wrapper containing a node and its cursor
2121+pub type Edge(node_type) {
2222+ Edge(node: node_type, cursor: String)
2323+}
2424+2525+/// Connection wrapper containing edges and page info
2626+pub type Connection(node_type) {
2727+ Connection(
2828+ edges: List(Edge(node_type)),
2929+ page_info: PageInfo,
3030+ total_count: Option(Int),
3131+ )
3232+}
3333+3434+/// Creates the PageInfo GraphQL type
3535+pub fn page_info_type() -> schema.Type {
3636+ schema.object_type(
3737+ "PageInfo",
3838+ "Information about pagination in a connection",
3939+ [
4040+ schema.field(
4141+ "hasNextPage",
4242+ schema.non_null(schema.boolean_type()),
4343+ "When paginating forwards, are there more items?",
4444+ fn(ctx) {
4545+ // Extract from context data
4646+ case ctx.data {
4747+ Some(value.Object(fields)) -> {
4848+ case list.key_find(fields, "hasNextPage") {
4949+ Ok(val) -> Ok(val)
5050+ Error(_) -> Ok(value.Boolean(False))
5151+ }
5252+ }
5353+ _ -> Ok(value.Boolean(False))
5454+ }
5555+ },
5656+ ),
5757+ schema.field(
5858+ "hasPreviousPage",
5959+ schema.non_null(schema.boolean_type()),
6060+ "When paginating backwards, are there more items?",
6161+ fn(ctx) {
6262+ case ctx.data {
6363+ Some(value.Object(fields)) -> {
6464+ case list.key_find(fields, "hasPreviousPage") {
6565+ Ok(val) -> Ok(val)
6666+ Error(_) -> Ok(value.Boolean(False))
6767+ }
6868+ }
6969+ _ -> Ok(value.Boolean(False))
7070+ }
7171+ },
7272+ ),
7373+ schema.field(
7474+ "startCursor",
7575+ schema.string_type(),
7676+ "Cursor corresponding to the first item in the page",
7777+ fn(ctx) {
7878+ case ctx.data {
7979+ Some(value.Object(fields)) -> {
8080+ case list.key_find(fields, "startCursor") {
8181+ Ok(val) -> Ok(val)
8282+ Error(_) -> Ok(value.Null)
8383+ }
8484+ }
8585+ _ -> Ok(value.Null)
8686+ }
8787+ },
8888+ ),
8989+ schema.field(
9090+ "endCursor",
9191+ schema.string_type(),
9292+ "Cursor corresponding to the last item in the page",
9393+ fn(ctx) {
9494+ case ctx.data {
9595+ Some(value.Object(fields)) -> {
9696+ case list.key_find(fields, "endCursor") {
9797+ Ok(val) -> Ok(val)
9898+ Error(_) -> Ok(value.Null)
9999+ }
100100+ }
101101+ _ -> Ok(value.Null)
102102+ }
103103+ },
104104+ ),
105105+ ],
106106+ )
107107+}
108108+109109+/// Creates an Edge type for a given node type name
110110+pub fn edge_type(node_type_name: String, node_type: schema.Type) -> schema.Type {
111111+ let edge_type_name = node_type_name <> "Edge"
112112+113113+ schema.object_type(
114114+ edge_type_name,
115115+ "An edge in a connection for " <> node_type_name,
116116+ [
117117+ schema.field(
118118+ "node",
119119+ schema.non_null(node_type),
120120+ "The item at the end of the edge",
121121+ fn(ctx) {
122122+ // Extract node from context data
123123+ case ctx.data {
124124+ Some(value.Object(fields)) -> {
125125+ case list.key_find(fields, "node") {
126126+ Ok(val) -> Ok(val)
127127+ Error(_) -> Ok(value.Null)
128128+ }
129129+ }
130130+ _ -> Ok(value.Null)
131131+ }
132132+ },
133133+ ),
134134+ schema.field(
135135+ "cursor",
136136+ schema.non_null(schema.string_type()),
137137+ "A cursor for use in pagination",
138138+ fn(ctx) {
139139+ case ctx.data {
140140+ Some(value.Object(fields)) -> {
141141+ case list.key_find(fields, "cursor") {
142142+ Ok(val) -> Ok(val)
143143+ Error(_) -> Ok(value.String(""))
144144+ }
145145+ }
146146+ _ -> Ok(value.String(""))
147147+ }
148148+ },
149149+ ),
150150+ ],
151151+ )
152152+}
153153+154154+/// Creates a Connection type for a given node type name
155155+pub fn connection_type(
156156+ node_type_name: String,
157157+ edge_type: schema.Type,
158158+) -> schema.Type {
159159+ let connection_type_name = node_type_name <> "Connection"
160160+161161+ schema.object_type(
162162+ connection_type_name,
163163+ "A connection to a list of items for " <> node_type_name,
164164+ [
165165+ schema.field(
166166+ "edges",
167167+ schema.non_null(schema.list_type(schema.non_null(edge_type))),
168168+ "A list of edges",
169169+ fn(ctx) {
170170+ // Extract edges from context data
171171+ case ctx.data {
172172+ Some(value.Object(fields)) -> {
173173+ case list.key_find(fields, "edges") {
174174+ Ok(val) -> Ok(val)
175175+ Error(_) -> Ok(value.List([]))
176176+ }
177177+ }
178178+ _ -> Ok(value.List([]))
179179+ }
180180+ },
181181+ ),
182182+ schema.field(
183183+ "pageInfo",
184184+ schema.non_null(page_info_type()),
185185+ "Information to aid in pagination",
186186+ fn(ctx) {
187187+ // Extract pageInfo from context data
188188+ case ctx.data {
189189+ Some(value.Object(fields)) -> {
190190+ case list.key_find(fields, "pageInfo") {
191191+ Ok(val) -> Ok(val)
192192+ Error(_) ->
193193+ Ok(
194194+ value.Object([
195195+ #("hasNextPage", value.Boolean(False)),
196196+ #("hasPreviousPage", value.Boolean(False)),
197197+ #("startCursor", value.Null),
198198+ #("endCursor", value.Null),
199199+ ]),
200200+ )
201201+ }
202202+ }
203203+ _ ->
204204+ Ok(
205205+ value.Object([
206206+ #("hasNextPage", value.Boolean(False)),
207207+ #("hasPreviousPage", value.Boolean(False)),
208208+ #("startCursor", value.Null),
209209+ #("endCursor", value.Null),
210210+ ]),
211211+ )
212212+ }
213213+ },
214214+ ),
215215+ schema.field(
216216+ "totalCount",
217217+ schema.int_type(),
218218+ "Total number of items in the connection",
219219+ fn(ctx) {
220220+ case ctx.data {
221221+ Some(value.Object(fields)) -> {
222222+ case list.key_find(fields, "totalCount") {
223223+ Ok(val) -> Ok(val)
224224+ Error(_) -> Ok(value.Null)
225225+ }
226226+ }
227227+ _ -> Ok(value.Null)
228228+ }
229229+ },
230230+ ),
231231+ ],
232232+ )
233233+}
234234+235235+/// Standard pagination arguments for forward pagination
236236+pub fn forward_pagination_args() -> List(schema.Argument) {
237237+ [
238238+ schema.argument(
239239+ "first",
240240+ schema.int_type(),
241241+ "Returns the first n items from the list",
242242+ None,
243243+ ),
244244+ schema.argument(
245245+ "after",
246246+ schema.string_type(),
247247+ "Returns items after the given cursor",
248248+ None,
249249+ ),
250250+ ]
251251+}
252252+253253+/// Standard pagination arguments for backward pagination
254254+pub fn backward_pagination_args() -> List(schema.Argument) {
255255+ [
256256+ schema.argument(
257257+ "last",
258258+ schema.int_type(),
259259+ "Returns the last n items from the list",
260260+ None,
261261+ ),
262262+ schema.argument(
263263+ "before",
264264+ schema.string_type(),
265265+ "Returns items before the given cursor",
266266+ None,
267267+ ),
268268+ ]
269269+}
270270+271271+/// All standard connection arguments (forward + backward)
272272+/// Note: sortBy is not included yet as it requires InputObject type support
273273+pub fn connection_args() -> List(schema.Argument) {
274274+ list.flatten([forward_pagination_args(), backward_pagination_args()])
275275+}
276276+277277+/// Converts a PageInfo value to a GraphQL value
278278+pub fn page_info_to_value(page_info: PageInfo) -> value.Value {
279279+ value.Object([
280280+ #("hasNextPage", value.Boolean(page_info.has_next_page)),
281281+ #("hasPreviousPage", value.Boolean(page_info.has_previous_page)),
282282+ #(
283283+ "startCursor",
284284+ case page_info.start_cursor {
285285+ Some(cursor) -> value.String(cursor)
286286+ None -> value.Null
287287+ },
288288+ ),
289289+ #(
290290+ "endCursor",
291291+ case page_info.end_cursor {
292292+ Some(cursor) -> value.String(cursor)
293293+ None -> value.Null
294294+ },
295295+ ),
296296+ ])
297297+}
298298+299299+/// Converts an Edge to a GraphQL value
300300+pub fn edge_to_value(edge: Edge(value.Value)) -> value.Value {
301301+ value.Object([
302302+ #("node", edge.node),
303303+ #("cursor", value.String(edge.cursor)),
304304+ ])
305305+}
306306+307307+/// Converts a Connection to a GraphQL value
308308+pub fn connection_to_value(connection: Connection(value.Value)) -> value.Value {
309309+ let edges_value =
310310+ connection.edges
311311+ |> list.map(edge_to_value)
312312+ |> value.List
313313+314314+ let total_count_value = case connection.total_count {
315315+ Some(count) -> value.Int(count)
316316+ None -> value.Null
317317+ }
318318+319319+ value.Object([
320320+ #("edges", edges_value),
321321+ #("pageInfo", page_info_to_value(connection.page_info)),
322322+ #("totalCount", total_count_value),
323323+ ])
324324+}
+11-3
graphql/src/graphql/executor.gleam
···342342 case field_value {
343343 value.Object(_) -> {
344344 // Execute nested selections using the field's type, not parent type
345345+ // Create new context with this object's data
346346+ let object_ctx = schema.Context(option.Some(field_value))
345347 let selection_set =
346348 parser.SelectionSet(nested_selections)
347349 case
···349351 selection_set,
350352 field_type_def,
351353 graphql_schema,
352352- ctx,
354354+ object_ctx,
353355 fragments,
354356 [name, ..path],
355357 )
···364366 }
365367 value.List(items) -> {
366368 // Handle list with nested selections
367367- // Get the inner type from the LIST wrapper
369369+ // Get the inner type from the LIST wrapper, unwrapping NonNull if needed
368370 let inner_type = case
369371 schema.inner_type(field_type_def)
370372 {
371371- option.Some(t) -> t
373373+ option.Some(t) -> {
374374+ // If the result is still wrapped (NonNull), unwrap it too
375375+ case schema.inner_type(t) {
376376+ option.Some(unwrapped) -> unwrapped
377377+ option.None -> t
378378+ }
379379+ }
372380 option.None -> field_type_def
373381 }
374382
···55import gleam/list
66import gleam/option
77import gleam/result
88+import graphql/connection
89import graphql/schema
910import graphql/value
1011import lexicon_graphql/nsid
···2122 )
2223}
23242424-/// Type for a database record fetcher function
2525-/// Takes a collection NSID and returns a list of records as GraphQL values
2525+/// Pagination parameters for connection queries
2626+pub type PaginationParams {
2727+ PaginationParams(
2828+ first: option.Option(Int),
2929+ after: option.Option(String),
3030+ last: option.Option(Int),
3131+ before: option.Option(String),
3232+ sort_by: option.Option(List(#(String, String))),
3333+ )
3434+}
3535+3636+/// Type for a database record fetcher function with pagination support
3737+/// Takes a collection NSID and pagination params, returns Connection data
3838+/// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page)
2639pub type RecordFetcher =
2727- fn(String) -> Result(List(value.Value), String)
4040+ fn(String, PaginationParams) ->
4141+ Result(
4242+ #(List(#(value.Value, String)), option.Option(String), Bool, Bool),
4343+ String,
4444+ )
28452946/// Build a GraphQL schema from lexicons with database-backed resolvers
3047///
···161178 record_type.fields,
162179 )
163180164164- // Create a list type for the query field
165165- let list_type = schema.list_type(object_type)
181181+ // Create Connection types
182182+ let edge_type = connection.edge_type(record_type.type_name, object_type)
183183+ let connection_type =
184184+ connection.connection_type(record_type.type_name, edge_type)
166185167167- // Create query field that returns a list of this record type
186186+ // Create query field that returns a Connection of this record type
168187 // Capture the nsid and fetcher in the closure
169188 let collection_nsid = record_type.nsid
170170- schema.field(
189189+ schema.field_with_args(
171190 record_type.field_name,
172172- list_type,
173173- "Query " <> record_type.nsid,
174174- fn(_ctx: schema.Context) {
175175- // Call the fetcher function to get records from database
176176- fetcher(collection_nsid)
177177- |> result.map(fn(records) { value.List(records) })
191191+ connection_type,
192192+ "Query " <> record_type.nsid <> " with cursor pagination",
193193+ connection.connection_args(),
194194+ fn(ctx: schema.Context) {
195195+ // Extract pagination arguments from context
196196+ let pagination_params = extract_pagination_params(ctx)
197197+198198+ // Call the fetcher function to get records with cursors from database
199199+ use #(records_with_cursors, end_cursor, has_next_page, has_previous_page) <- result.try(
200200+ fetcher(collection_nsid, pagination_params),
201201+ )
202202+203203+ // Build edges from records with their cursors
204204+ let edges =
205205+ list.map(records_with_cursors, fn(record_tuple) {
206206+ let #(record_value, record_cursor) = record_tuple
207207+ connection.Edge(node: record_value, cursor: record_cursor)
208208+ })
209209+210210+ // Build PageInfo
211211+ let page_info =
212212+ connection.PageInfo(
213213+ has_next_page: has_next_page,
214214+ has_previous_page: has_previous_page,
215215+ start_cursor: case list.first(edges) {
216216+ Ok(edge) -> option.Some(edge.cursor)
217217+ Error(_) -> option.None
218218+ },
219219+ end_cursor: end_cursor,
220220+ )
221221+222222+ // Build Connection
223223+ let conn =
224224+ connection.Connection(
225225+ edges: edges,
226226+ page_info: page_info,
227227+ total_count: option.None,
228228+ )
229229+230230+ Ok(connection.connection_to_value(conn))
178231 },
179232 )
180233 })
181234182235 schema.object_type("Query", "Root query type", query_fields)
236236+}
237237+238238+/// Extract pagination parameters from GraphQL context
239239+fn extract_pagination_params(ctx: schema.Context) -> PaginationParams {
240240+ let _ = ctx
241241+ // TODO: In a full implementation, arguments would be extracted from context
242242+ // Currently the GraphQL executor doesn't pass arguments to resolvers
243243+ // For now, we use sensible defaults:
244244+ // - Default to 50 items per page
245245+ // - Sort by indexed_at DESC (most recent first)
246246+ PaginationParams(
247247+ first: option.Some(50),
248248+ after: option.None,
249249+ last: option.None,
250250+ before: option.None,
251251+ sort_by: option.Some([#("indexed_at", "desc")]),
252252+ )
183253}
184254185255/// Helper to extract a field value from resolver context
+363
server/src/cursor.gleam
···11+/// Cursor-based pagination utilities.
22+///
33+/// Cursors encode the position in a result set as base64(field1|field2|...|cid)
44+/// to enable stable pagination even when new records are inserted.
55+///
66+/// The cursor format:
77+/// - All sort field values are included in the cursor
88+/// - Values are separated by pipe (|) characters
99+/// - CID is always the last element as the ultimate tiebreaker
1010+import gleam/bit_array
1111+import gleam/dict
1212+import gleam/dynamic
1313+import gleam/dynamic/decode
1414+import gleam/float
1515+import gleam/int
1616+import gleam/json
1717+import gleam/list
1818+import gleam/option.{type Option, None, Some}
1919+import gleam/result
2020+import gleam/string
2121+2222+/// Decoded cursor components for pagination
2323+pub type DecodedCursor {
2424+ DecodedCursor(
2525+ /// Field values in the order they appear in sortBy
2626+ field_values: List(String),
2727+ /// CID (always the last element)
2828+ cid: String,
2929+ )
3030+}
3131+3232+/// Encodes a string to URL-safe base64 without padding
3333+pub fn encode_base64(input: String) -> String {
3434+ let bytes = bit_array.from_string(input)
3535+ let encoded = bit_array.base64_url_encode(bytes, False)
3636+ encoded
3737+}
3838+3939+/// Decodes a URL-safe base64 string without padding
4040+pub fn decode_base64(input: String) -> Result(String, String) {
4141+ case bit_array.base64_url_decode(input) {
4242+ Ok(bytes) ->
4343+ case bit_array.to_string(bytes) {
4444+ Ok(str) -> Ok(str)
4545+ Error(_) -> Error("Invalid UTF-8 in cursor")
4646+ }
4747+ Error(_) -> Error("Failed to decode base64")
4848+ }
4949+}
5050+5151+/// Record-like type for cursor generation
5252+/// This allows cursor to work with any record type without importing database
5353+pub type RecordLike {
5454+ RecordLike(
5555+ uri: String,
5656+ cid: String,
5757+ did: String,
5858+ collection: String,
5959+ json: String,
6060+ indexed_at: String,
6161+ )
6262+}
6363+6464+/// Extracts a field value from a record.
6565+///
6666+/// Handles both table columns and JSON fields with nested paths.
6767+pub fn extract_field_value(record: RecordLike, field: String) -> String {
6868+ case field {
6969+ "uri" -> record.uri
7070+ "cid" -> record.cid
7171+ "did" -> record.did
7272+ "collection" -> record.collection
7373+ "indexed_at" -> record.indexed_at
7474+ _ -> extract_json_field(record.json, field)
7575+ }
7676+}
7777+7878+/// Extracts a value from a JSON string using a field path
7979+fn extract_json_field(json_str: String, field: String) -> String {
8080+ // Parse the JSON as a dictionary
8181+ let decoder = decode.dict(decode.string, decode.dynamic)
8282+ case json.parse(json_str, decoder) {
8383+ Error(_) -> "NULL"
8484+ Ok(dict) -> {
8585+ // Split field path by dots for nested access
8686+ let path_parts = string.split(field, ".")
8787+8888+ // Navigate through the JSON structure
8989+ extract_from_dict(dict, path_parts)
9090+ }
9191+ }
9292+}
9393+9494+/// Recursively extracts a value from a dict using a path
9595+fn extract_from_dict(
9696+ dict: dict.Dict(String, dynamic.Dynamic),
9797+ path: List(String),
9898+) -> String {
9999+ case path {
100100+ [] -> "NULL"
101101+ [key] -> {
102102+ // Final key - extract and convert to string
103103+ case dict.get(dict, key) {
104104+ Ok(val) -> dynamic_to_string(val)
105105+ Error(_) -> "NULL"
106106+ }
107107+ }
108108+ [key, ..rest] -> {
109109+ // Intermediate key - try to decode as nested dict
110110+ case dict.get(dict, key) {
111111+ Ok(val) -> {
112112+ case decode.run(val, decode.dict(decode.string, decode.dynamic)) {
113113+ Ok(nested_dict) -> extract_from_dict(nested_dict, rest)
114114+ Error(_) -> "NULL"
115115+ }
116116+ }
117117+ Error(_) -> "NULL"
118118+ }
119119+ }
120120+ }
121121+}
122122+123123+/// Converts a dynamic JSON value to a string representation
124124+fn dynamic_to_string(value: dynamic.Dynamic) -> String {
125125+ // Try to decode as string
126126+ case decode.run(value, decode.string) {
127127+ Ok(s) -> s
128128+ Error(_) ->
129129+ // Try as int
130130+ case decode.run(value, decode.int) {
131131+ Ok(i) -> int.to_string(i)
132132+ Error(_) ->
133133+ // Try as float
134134+ case decode.run(value, decode.float) {
135135+ Ok(f) -> float.to_string(f)
136136+ Error(_) ->
137137+ // Try as bool
138138+ case decode.run(value, decode.bool) {
139139+ Ok(b) ->
140140+ case b {
141141+ True -> "true"
142142+ False -> "false"
143143+ }
144144+ Error(_) -> "NULL"
145145+ }
146146+ }
147147+ }
148148+ }
149149+}
150150+151151+/// Generates a cursor from a record based on the sort configuration.
152152+///
153153+/// Extracts all sort field values from the record and encodes them along with the CID.
154154+/// Format: `base64(field1_value|field2_value|...|cid)`
155155+pub fn generate_cursor_from_record(
156156+ record: RecordLike,
157157+ sort_by: Option(List(#(String, String))),
158158+) -> String {
159159+ let cursor_parts = case sort_by {
160160+ None -> []
161161+ Some(sort_fields) -> {
162162+ list.map(sort_fields, fn(sort_field) {
163163+ let #(field, _direction) = sort_field
164164+ extract_field_value(record, field)
165165+ })
166166+ }
167167+ }
168168+169169+ // Always add CID as the final tiebreaker
170170+ let all_parts = list.append(cursor_parts, [record.cid])
171171+172172+ // Join with pipe and encode
173173+ let cursor_content = string.join(all_parts, "|")
174174+ encode_base64(cursor_content)
175175+}
176176+177177+/// Decodes a base64-encoded cursor back into its components.
178178+///
179179+/// The cursor format is: `base64(field1|field2|...|cid)`
180180+pub fn decode_cursor(
181181+ cursor: String,
182182+ sort_by: Option(List(#(String, String))),
183183+) -> Result(DecodedCursor, String) {
184184+ use decoded_str <- result.try(decode_base64(cursor))
185185+186186+ let parts = string.split(decoded_str, "|")
187187+188188+ // Validate cursor format matches sortBy fields
189189+ let expected_parts = case sort_by {
190190+ None -> 1
191191+ Some(fields) -> list.length(fields) + 1
192192+ }
193193+194194+ case list.length(parts) == expected_parts {
195195+ False ->
196196+ Error(
197197+ "Invalid cursor format: expected "
198198+ <> int.to_string(expected_parts)
199199+ <> " parts, got "
200200+ <> int.to_string(list.length(parts)),
201201+ )
202202+ True -> {
203203+ // Last part is the CID
204204+ case list.reverse(parts) {
205205+ [cid, ..rest_reversed] -> {
206206+ let field_values = list.reverse(rest_reversed)
207207+ Ok(DecodedCursor(field_values: field_values, cid: cid))
208208+ }
209209+ [] -> Error("Cursor has no parts")
210210+ }
211211+ }
212212+ }
213213+}
214214+215215+/// Builds cursor-based WHERE conditions for proper multi-field pagination.
216216+///
217217+/// Creates progressive equality checks for stable multi-field sorting.
218218+/// For each field, we OR together:
219219+/// 1. field1 > cursor_value1
220220+/// 2. field1 = cursor_value1 AND field2 > cursor_value2
221221+/// 3. field1 = cursor_value1 AND field2 = cursor_value2 AND field3 > cursor_value3
222222+/// ... and so on
223223+/// Finally: all fields equal AND cid > cursor_cid
224224+///
225225+/// Returns: #(where_clause_sql, bind_values)
226226+pub fn build_cursor_where_clause(
227227+ decoded_cursor: DecodedCursor,
228228+ sort_by: Option(List(#(String, String))),
229229+ is_before: Bool,
230230+) -> #(String, List(String)) {
231231+ let sort_fields = case sort_by {
232232+ None -> []
233233+ Some(fields) -> fields
234234+ }
235235+236236+ case list.is_empty(sort_fields) {
237237+ True -> #("1=1", [])
238238+ False -> {
239239+ let clauses = build_progressive_clauses(
240240+ sort_fields,
241241+ decoded_cursor.field_values,
242242+ decoded_cursor.cid,
243243+ is_before,
244244+ )
245245+246246+ let sql = "(" <> string.join(clauses.0, " OR ") <> ")"
247247+ #(sql, clauses.1)
248248+ }
249249+ }
250250+}
251251+252252+/// Builds progressive equality clauses for cursor pagination
253253+fn build_progressive_clauses(
254254+ sort_fields: List(#(String, String)),
255255+ field_values: List(String),
256256+ cid: String,
257257+ is_before: Bool,
258258+) -> #(List(String), List(String)) {
259259+ let _field_count = list.length(sort_fields)
260260+261261+ // Build clauses for each level
262262+ let #(clauses, params) =
263263+ list.index_map(sort_fields, fn(field, i) {
264264+ // Build equality checks for fields [0..i-1]
265265+ let #(equality_parts, equality_params) = case i {
266266+ 0 -> #([], [])
267267+ _ -> {
268268+ list.range(0, i - 1)
269269+ |> list.fold(#([], []), fn(eq_acc, j) {
270270+ let #(eq_parts, eq_params) = eq_acc
271271+ let prior_field = list_at(sort_fields, j) |> result.unwrap(#("", ""))
272272+ let value = list_at(field_values, j) |> result.unwrap("")
273273+274274+ let field_ref = build_field_reference(prior_field.0)
275275+ let new_part = field_ref <> " = ?"
276276+ let new_params = list.append(eq_params, [value])
277277+278278+ #(list.append(eq_parts, [new_part]), new_params)
279279+ })
280280+ }
281281+ }
282282+283283+ // Add comparison for current field
284284+ let value = list_at(field_values, i) |> result.unwrap("")
285285+286286+ let comparison_op = get_comparison_operator(field.1, is_before)
287287+ let field_ref = build_field_reference(field.0)
288288+289289+ let comparison_part = field_ref <> " " <> comparison_op <> " ?"
290290+ let all_parts = list.append(equality_parts, [comparison_part])
291291+ let all_params = list.append(equality_params, [value])
292292+293293+ // Combine with AND
294294+ let clause = "(" <> string.join(all_parts, " AND ") <> ")"
295295+296296+ #(clause, all_params)
297297+ })
298298+ |> list.unzip
299299+ |> fn(unzipped) {
300300+ // Flatten the params lists
301301+ let flattened_params = list.flatten(unzipped.1)
302302+ #(unzipped.0, flattened_params)
303303+ }
304304+305305+ // Add final clause: all fields equal AND cid comparison
306306+ let #(final_equality_parts, final_equality_params) =
307307+ list.index_map(sort_fields, fn(field, j) {
308308+ let value = list_at(field_values, j) |> result.unwrap("")
309309+ let field_ref = build_field_reference(field.0)
310310+ #(field_ref <> " = ?", value)
311311+ })
312312+ |> list.unzip
313313+314314+ // CID comparison uses the direction of the last sort field
315315+ let last_field = list.last(sort_fields) |> result.unwrap(#("", "desc"))
316316+ let cid_comparison_op = get_comparison_operator(last_field.1, is_before)
317317+318318+ let final_parts = list.append(final_equality_parts, ["cid " <> cid_comparison_op <> " ?"])
319319+ let final_params = list.append(final_equality_params, [cid])
320320+321321+ let final_clause = "(" <> string.join(final_parts, " AND ") <> ")"
322322+ let all_clauses = list.append(clauses, [final_clause])
323323+ let all_params = list.append(params, final_params)
324324+325325+ #(all_clauses, all_params)
326326+}
327327+328328+/// Builds a field reference for SQL queries (handles JSON fields)
329329+fn build_field_reference(field: String) -> String {
330330+ case field {
331331+ "uri" | "cid" | "did" | "collection" | "indexed_at" -> field
332332+ _ -> {
333333+ // JSON field - use json_extract with JSON path
334334+ let json_path = "$." <> string.replace(field, ".", ".")
335335+ "json_extract(json, '" <> json_path <> "')"
336336+ }
337337+ }
338338+}
339339+340340+/// Gets the comparison operator based on sort direction and pagination direction
341341+fn get_comparison_operator(direction: String, is_before: Bool) -> String {
342342+ let is_desc = string.lowercase(direction) == "desc"
343343+344344+ case is_before {
345345+ True ->
346346+ case is_desc {
347347+ True -> ">"
348348+ False -> "<"
349349+ }
350350+ False ->
351351+ case is_desc {
352352+ True -> "<"
353353+ False -> ">"
354354+ }
355355+ }
356356+}
357357+358358+/// Helper to get an element at an index from a list
359359+fn list_at(list: List(a), index: Int) -> Result(a, Nil) {
360360+ list
361361+ |> list.drop(index)
362362+ |> list.first
363363+}
+168
server/src/database.gleam
···11+import cursor
12import gleam/dynamic/decode
33+import gleam/int
24import gleam/io
55+import gleam/list
66+import gleam/option.{type Option, None, Some}
37import gleam/result
88+import gleam/string
49import sqlight
510611pub type Record {
···569574570575 sqlight.query(sql, on: conn, with: [], expecting: decoder)
571576}
577577+578578+/// Paginated query for records with cursor-based pagination
579579+///
580580+/// Supports both forward (first/after) and backward (last/before) pagination.
581581+/// Returns a tuple of (records, next_cursor, has_next_page, has_previous_page)
582582+pub fn get_records_by_collection_paginated(
583583+ conn: sqlight.Connection,
584584+ collection: String,
585585+ first: Option(Int),
586586+ after: Option(String),
587587+ last: Option(Int),
588588+ before: Option(String),
589589+ sort_by: Option(List(#(String, String))),
590590+) -> Result(#(List(Record), Option(String), Bool, Bool), sqlight.Error) {
591591+ // Validate pagination arguments
592592+ let #(limit, is_forward, cursor_opt) = case first, last {
593593+ Some(f), None -> #(f, True, after)
594594+ None, Some(l) -> #(l, False, before)
595595+ Some(f), Some(_) ->
596596+ // Both first and last specified - use first
597597+ #(f, True, after)
598598+ None, None ->
599599+ // Neither specified - default to first 50
600600+ #(50, True, None)
601601+ }
602602+603603+ // Default sort order if not specified
604604+ let sort_fields = case sort_by {
605605+ Some(fields) -> fields
606606+ None -> [#("indexed_at", "desc")]
607607+ }
608608+609609+ // Build the ORDER BY clause
610610+ let order_by_clause = build_order_by(sort_fields)
611611+612612+ // Build WHERE clause parts
613613+ let where_parts = ["collection = ?"]
614614+ let bind_values = [sqlight.text(collection)]
615615+616616+ // Add cursor condition if present
617617+ let #(final_where_parts, final_bind_values) = case cursor_opt {
618618+ Some(cursor_str) -> {
619619+ case cursor.decode_cursor(cursor_str, sort_by) {
620620+ Ok(decoded_cursor) -> {
621621+ let #(cursor_where, cursor_params) =
622622+ cursor.build_cursor_where_clause(
623623+ decoded_cursor,
624624+ sort_by,
625625+ !is_forward,
626626+ )
627627+628628+ let new_where = list.append(where_parts, [cursor_where])
629629+ let new_binds =
630630+ list.append(
631631+ bind_values,
632632+ list.map(cursor_params, sqlight.text),
633633+ )
634634+ #(new_where, new_binds)
635635+ }
636636+ Error(_) -> #(where_parts, bind_values)
637637+ }
638638+ }
639639+ None -> #(where_parts, bind_values)
640640+ }
641641+642642+ // Fetch limit + 1 to detect if there are more pages
643643+ let fetch_limit = limit + 1
644644+645645+ // Build the SQL query
646646+ let sql =
647647+ "
648648+ SELECT uri, cid, did, collection, json, indexed_at
649649+ FROM record
650650+ WHERE "
651651+ <> string.join(final_where_parts, " AND ")
652652+ <> "
653653+ ORDER BY "
654654+ <> order_by_clause
655655+ <> "
656656+ LIMIT "
657657+ <> int.to_string(fetch_limit)
658658+659659+ // Execute query
660660+ let decoder = {
661661+ use uri <- decode.field(0, decode.string)
662662+ use cid <- decode.field(1, decode.string)
663663+ use did <- decode.field(2, decode.string)
664664+ use collection <- decode.field(3, decode.string)
665665+ use json <- decode.field(4, decode.string)
666666+ use indexed_at <- decode.field(5, decode.string)
667667+ decode.success(Record(uri:, cid:, did:, collection:, json:, indexed_at:))
668668+ }
669669+670670+ use records <- result.try(sqlight.query(
671671+ sql,
672672+ on: conn,
673673+ with: final_bind_values,
674674+ expecting: decoder,
675675+ ))
676676+677677+ // Check if there are more results
678678+ let has_more = list.length(records) > limit
679679+ let final_records = case has_more {
680680+ True -> list.take(records, limit)
681681+ False -> records
682682+ }
683683+684684+ // Calculate hasNextPage and hasPreviousPage
685685+ let has_next_page = case is_forward {
686686+ True -> has_more
687687+ False -> option.is_some(cursor_opt)
688688+ }
689689+690690+ let has_previous_page = case is_forward {
691691+ True -> option.is_some(cursor_opt)
692692+ False -> has_more
693693+ }
694694+695695+ // Generate next cursor if there are more results
696696+ let next_cursor = case has_more, list.last(final_records) {
697697+ True, Ok(last_record) -> {
698698+ let record_like = record_to_record_like(last_record)
699699+ Some(cursor.generate_cursor_from_record(record_like, sort_by))
700700+ }
701701+ _, _ -> None
702702+ }
703703+704704+ Ok(#(final_records, next_cursor, has_next_page, has_previous_page))
705705+}
706706+707707+/// Converts a database Record to a cursor.RecordLike
708708+pub fn record_to_record_like(record: Record) -> cursor.RecordLike {
709709+ cursor.RecordLike(
710710+ uri: record.uri,
711711+ cid: record.cid,
712712+ did: record.did,
713713+ collection: record.collection,
714714+ json: record.json,
715715+ indexed_at: record.indexed_at,
716716+ )
717717+}
718718+719719+/// Builds an ORDER BY clause from sort fields
720720+fn build_order_by(sort_fields: List(#(String, String))) -> String {
721721+ let order_parts =
722722+ list.map(sort_fields, fn(field) {
723723+ let #(field_name, direction) = field
724724+ let field_ref = case field_name {
725725+ "uri" | "cid" | "did" | "collection" | "indexed_at" -> field_name
726726+ _ -> "json_extract(json, '$." <> field_name <> "')"
727727+ }
728728+ let dir = case string.lowercase(direction) {
729729+ "asc" -> "ASC"
730730+ _ -> "DESC"
731731+ }
732732+ field_ref <> " " <> dir
733733+ })
734734+735735+ case list.is_empty(order_parts) {
736736+ True -> "indexed_at DESC"
737737+ False -> string.join(order_parts, ", ")
738738+ }
739739+}
+40-12
server/src/graphql_gleam.gleam
···22///
33/// This module provides GraphQL schema building and query execution using
44/// pure Gleam code, replacing the previous Elixir FFI implementation.
55+import cursor
56import database
67import gleam/dict
78import gleam/dynamic
···4142 case parsed_lexicons {
4243 [] -> Error("No valid lexicons found in database")
4344 _ -> {
4444- // Step 3: Create a record fetcher function that queries the database
4545- let record_fetcher = fn(collection_nsid: String) -> Result(
4646- List(value.Value),
4545+ // Step 3: Create a record fetcher function that queries the database with pagination
4646+ let record_fetcher = fn(
4747+ collection_nsid: String,
4848+ pagination_params: db_schema_builder.PaginationParams,
4949+ ) -> Result(
5050+ #(List(#(value.Value, String)), option.Option(String), Bool, Bool),
4751 String,
4852 ) {
4949- // Fetch records from database for this collection
5050- case database.get_records_by_collection(db, collection_nsid) {
5151- Error(_) -> Ok([])
5252- // Return empty list if no records found
5353- Ok(records) -> {
5454- // Convert database records to GraphQL values
5555- let graphql_records =
5656- list.map(records, fn(record) { record_to_graphql_value(record) })
5757- Ok(graphql_records)
5353+ // Fetch records from database for this collection with pagination
5454+ case
5555+ database.get_records_by_collection_paginated(
5656+ db,
5757+ collection_nsid,
5858+ pagination_params.first,
5959+ pagination_params.after,
6060+ pagination_params.last,
6161+ pagination_params.before,
6262+ pagination_params.sort_by,
6363+ )
6464+ {
6565+ Error(_) -> Ok(#([], option.None, False, False))
6666+ // Return empty result on error
6767+ Ok(#(records, next_cursor, has_next_page, has_previous_page)) -> {
6868+ // Convert database records to GraphQL values with cursors
6969+ let graphql_records_with_cursors =
7070+ list.map(records, fn(record) {
7171+ let graphql_value = record_to_graphql_value(record)
7272+ // Generate cursor for this record
7373+ let record_cursor =
7474+ cursor.generate_cursor_from_record(
7575+ database.record_to_record_like(record),
7676+ pagination_params.sort_by,
7777+ )
7878+ #(graphql_value, record_cursor)
7979+ })
8080+ Ok(#(
8181+ graphql_records_with_cursors,
8282+ next_cursor,
8383+ has_next_page,
8484+ has_previous_page,
8585+ ))
5886 }
5987 }
6088 }
+397
server/test/cursor_test.gleam
···11+import cursor
22+import gleam/option.{None, Some}
33+import gleeunit/should
44+55+/// Test encoding a cursor with no sort fields (just CID)
66+pub fn encode_cursor_no_sort_test() {
77+ let record =
88+ cursor.RecordLike(
99+ uri: "at://did:plc:test/app.bsky.feed.post/123",
1010+ cid: "bafytest123",
1111+ did: "did:plc:test",
1212+ collection: "app.bsky.feed.post",
1313+ json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}",
1414+ indexed_at: "2025-01-15 12:00:00",
1515+ )
1616+1717+ let result = cursor.generate_cursor_from_record(record, None)
1818+1919+ // Decode the base64 to verify it's just the CID
2020+ let decoded = cursor.decode_base64(result)
2121+ should.be_ok(decoded)
2222+ |> should.equal("bafytest123")
2323+}
2424+2525+/// Test encoding a cursor with single sort field
2626+pub fn encode_cursor_single_field_test() {
2727+ let record =
2828+ cursor.RecordLike(
2929+ uri: "at://did:plc:test/app.bsky.feed.post/123",
3030+ cid: "bafytest123",
3131+ did: "did:plc:test",
3232+ collection: "app.bsky.feed.post",
3333+ json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}",
3434+ indexed_at: "2025-01-15 12:00:00",
3535+ )
3636+3737+ let sort_by = Some([#("indexed_at", "desc")])
3838+3939+ let result = cursor.generate_cursor_from_record(record, sort_by)
4040+4141+ // Decode the base64 to verify format
4242+ let decoded = cursor.decode_base64(result)
4343+ should.be_ok(decoded)
4444+ |> should.equal("2025-01-15 12:00:00|bafytest123")
4545+}
4646+4747+/// Test encoding a cursor with JSON field
4848+pub fn encode_cursor_json_field_test() {
4949+ let record =
5050+ cursor.RecordLike(
5151+ uri: "at://did:plc:test/app.bsky.feed.post/123",
5252+ cid: "bafytest123",
5353+ did: "did:plc:test",
5454+ collection: "app.bsky.feed.post",
5555+ json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}",
5656+ indexed_at: "2025-01-15 12:00:00",
5757+ )
5858+5959+ let sort_by = Some([#("text", "desc")])
6060+6161+ let result = cursor.generate_cursor_from_record(record, sort_by)
6262+6363+ let decoded = cursor.decode_base64(result)
6464+ should.be_ok(decoded)
6565+ |> should.equal("Hello world|bafytest123")
6666+}
6767+6868+/// Test encoding a cursor with nested JSON field
6969+pub fn encode_cursor_nested_json_field_test() {
7070+ let record =
7171+ cursor.RecordLike(
7272+ uri: "at://did:plc:test/app.bsky.feed.post/123",
7373+ cid: "bafytest123",
7474+ did: "did:plc:test",
7575+ collection: "app.bsky.feed.post",
7676+ json: "{\"author\":{\"name\":\"Alice\"},\"createdAt\":\"2025-01-15T12:00:00Z\"}",
7777+ indexed_at: "2025-01-15 12:00:00",
7878+ )
7979+8080+ let sort_by = Some([#("author.name", "asc")])
8181+8282+ let result = cursor.generate_cursor_from_record(record, sort_by)
8383+8484+ let decoded = cursor.decode_base64(result)
8585+ should.be_ok(decoded)
8686+ |> should.equal("Alice|bafytest123")
8787+}
8888+8989+/// Test encoding a cursor with multiple sort fields
9090+pub fn encode_cursor_multi_field_test() {
9191+ let record =
9292+ cursor.RecordLike(
9393+ uri: "at://did:plc:test/app.bsky.feed.post/123",
9494+ cid: "bafytest123",
9595+ did: "did:plc:test",
9696+ collection: "app.bsky.feed.post",
9797+ json: "{\"text\":\"Hello\",\"createdAt\":\"2025-01-15T12:00:00Z\"}",
9898+ indexed_at: "2025-01-15 12:00:00",
9999+ )
100100+101101+ let sort_by = Some([#("text", "desc"), #("createdAt", "desc")])
102102+103103+ let result = cursor.generate_cursor_from_record(record, sort_by)
104104+105105+ let decoded = cursor.decode_base64(result)
106106+ should.be_ok(decoded)
107107+ |> should.equal("Hello|2025-01-15T12:00:00Z|bafytest123")
108108+}
109109+110110+/// Test decoding a valid cursor
111111+pub fn decode_cursor_valid_test() {
112112+ let sort_by = Some([#("indexed_at", "desc")])
113113+114114+ // Create a cursor: "2025-01-15 12:00:00|bafytest123"
115115+ let cursor_str = cursor.encode_base64("2025-01-15 12:00:00|bafytest123")
116116+117117+ let result = cursor.decode_cursor(cursor_str, sort_by)
118118+119119+ should.be_ok(result)
120120+ |> fn(decoded) {
121121+ decoded.field_values
122122+ |> should.equal(["2025-01-15 12:00:00"])
123123+124124+ decoded.cid
125125+ |> should.equal("bafytest123")
126126+ }
127127+}
128128+129129+/// Test decoding a multi-field cursor
130130+pub fn decode_cursor_multi_field_test() {
131131+ let sort_by = Some([#("text", "desc"), #("createdAt", "desc")])
132132+133133+ let cursor_str = cursor.encode_base64("Hello|2025-01-15T12:00:00Z|bafytest123")
134134+135135+ let result = cursor.decode_cursor(cursor_str, sort_by)
136136+137137+ should.be_ok(result)
138138+ |> fn(decoded) {
139139+ decoded.field_values
140140+ |> should.equal(["Hello", "2025-01-15T12:00:00Z"])
141141+142142+ decoded.cid
143143+ |> should.equal("bafytest123")
144144+ }
145145+}
146146+147147+/// Test decoding with mismatched field count fails
148148+pub fn decode_cursor_mismatch_test() {
149149+ let sort_by = Some([#("text", "desc")])
150150+151151+ // Cursor has 2 fields but sort_by only has 1
152152+ let cursor_str = cursor.encode_base64("Hello|2025-01-15T12:00:00Z|bafytest123")
153153+154154+ let result = cursor.decode_cursor(cursor_str, sort_by)
155155+156156+ should.be_error(result)
157157+}
158158+159159+/// Test decoding invalid base64 fails
160160+pub fn decode_cursor_invalid_base64_test() {
161161+ let sort_by = Some([#("text", "desc")])
162162+163163+ let result = cursor.decode_cursor("not-valid-base64!!!", sort_by)
164164+165165+ should.be_error(result)
166166+}
167167+168168+/// Test extracting table column values
169169+pub fn extract_field_value_table_column_test() {
170170+ let record =
171171+ cursor.RecordLike(
172172+ uri: "at://did:plc:test/app.bsky.feed.post/123",
173173+ cid: "bafytest123",
174174+ did: "did:plc:test",
175175+ collection: "app.bsky.feed.post",
176176+ json: "{}",
177177+ indexed_at: "2025-01-15 12:00:00",
178178+ )
179179+180180+ cursor.extract_field_value(record, "uri")
181181+ |> should.equal("at://did:plc:test/app.bsky.feed.post/123")
182182+183183+ cursor.extract_field_value(record, "cid")
184184+ |> should.equal("bafytest123")
185185+186186+ cursor.extract_field_value(record, "did")
187187+ |> should.equal("did:plc:test")
188188+189189+ cursor.extract_field_value(record, "collection")
190190+ |> should.equal("app.bsky.feed.post")
191191+192192+ cursor.extract_field_value(record, "indexed_at")
193193+ |> should.equal("2025-01-15 12:00:00")
194194+}
195195+196196+/// Test extracting JSON field values
197197+pub fn extract_field_value_json_test() {
198198+ let record =
199199+ cursor.RecordLike(
200200+ uri: "at://did:plc:test/app.bsky.feed.post/123",
201201+ cid: "bafytest123",
202202+ did: "did:plc:test",
203203+ collection: "app.bsky.feed.post",
204204+ json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\",\"likeCount\":42}",
205205+ indexed_at: "2025-01-15 12:00:00",
206206+ )
207207+208208+ cursor.extract_field_value(record, "text")
209209+ |> should.equal("Hello world")
210210+211211+ cursor.extract_field_value(record, "createdAt")
212212+ |> should.equal("2025-01-15T12:00:00Z")
213213+214214+ cursor.extract_field_value(record, "likeCount")
215215+ |> should.equal("42")
216216+}
217217+218218+/// Test extracting nested JSON field values
219219+pub fn extract_field_value_nested_json_test() {
220220+ let record =
221221+ cursor.RecordLike(
222222+ uri: "at://did:plc:test/app.bsky.feed.post/123",
223223+ cid: "bafytest123",
224224+ did: "did:plc:test",
225225+ collection: "app.bsky.feed.post",
226226+ json: "{\"author\":{\"name\":\"Alice\",\"did\":\"did:plc:alice\"}}",
227227+ indexed_at: "2025-01-15 12:00:00",
228228+ )
229229+230230+ cursor.extract_field_value(record, "author.name")
231231+ |> should.equal("Alice")
232232+233233+ cursor.extract_field_value(record, "author.did")
234234+ |> should.equal("did:plc:alice")
235235+}
236236+237237+/// Test extracting missing JSON field returns NULL
238238+pub fn extract_field_value_missing_test() {
239239+ let record =
240240+ cursor.RecordLike(
241241+ uri: "at://did:plc:test/app.bsky.feed.post/123",
242242+ cid: "bafytest123",
243243+ did: "did:plc:test",
244244+ collection: "app.bsky.feed.post",
245245+ json: "{\"text\":\"Hello\"}",
246246+ indexed_at: "2025-01-15 12:00:00",
247247+ )
248248+249249+ cursor.extract_field_value(record, "nonexistent")
250250+ |> should.equal("NULL")
251251+252252+ cursor.extract_field_value(record, "author.name")
253253+ |> should.equal("NULL")
254254+}
255255+256256+// WHERE Condition Builder Tests
257257+258258+/// Test building WHERE clause for single field DESC
259259+pub fn build_where_single_field_desc_test() {
260260+ let decoded = cursor.DecodedCursor(
261261+ field_values: ["2025-01-15 12:00:00"],
262262+ cid: "bafytest123",
263263+ )
264264+265265+ let sort_by = Some([#("indexed_at", "desc")])
266266+267267+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False)
268268+269269+ // For DESC: indexed_at < cursor_value OR (indexed_at = cursor_value AND cid < cursor_cid)
270270+ sql
271271+ |> should.equal("((indexed_at < ?) OR (indexed_at = ? AND cid < ?))")
272272+273273+ params
274274+ |> should.equal([
275275+ "2025-01-15 12:00:00",
276276+ "2025-01-15 12:00:00",
277277+ "bafytest123",
278278+ ])
279279+}
280280+281281+/// Test building WHERE clause for single field ASC
282282+pub fn build_where_single_field_asc_test() {
283283+ let decoded = cursor.DecodedCursor(
284284+ field_values: ["2025-01-15 12:00:00"],
285285+ cid: "bafytest123",
286286+ )
287287+288288+ let sort_by = Some([#("indexed_at", "asc")])
289289+290290+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False)
291291+292292+ // For ASC: indexed_at > cursor_value OR (indexed_at = cursor_value AND cid > cursor_cid)
293293+ sql
294294+ |> should.equal("((indexed_at > ?) OR (indexed_at = ? AND cid > ?))")
295295+296296+ params
297297+ |> should.equal([
298298+ "2025-01-15 12:00:00",
299299+ "2025-01-15 12:00:00",
300300+ "bafytest123",
301301+ ])
302302+}
303303+304304+/// Test building WHERE clause for JSON field
305305+pub fn build_where_json_field_test() {
306306+ let decoded = cursor.DecodedCursor(
307307+ field_values: ["Hello world"],
308308+ cid: "bafytest123",
309309+ )
310310+311311+ let sort_by = Some([#("text", "desc")])
312312+313313+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False)
314314+315315+ // JSON fields use json_extract
316316+ sql
317317+ |> should.equal(
318318+ "((json_extract(json, '$.text') < ?) OR (json_extract(json, '$.text') = ? AND cid < ?))",
319319+ )
320320+321321+ params
322322+ |> should.equal(["Hello world", "Hello world", "bafytest123"])
323323+}
324324+325325+/// Test building WHERE clause for nested JSON field
326326+pub fn build_where_nested_json_field_test() {
327327+ let decoded = cursor.DecodedCursor(
328328+ field_values: ["Alice"],
329329+ cid: "bafytest123",
330330+ )
331331+332332+ let sort_by = Some([#("author.name", "asc")])
333333+334334+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False)
335335+336336+ // Nested JSON fields use $.path.to.field
337337+ sql
338338+ |> should.equal(
339339+ "((json_extract(json, '$.author.name') > ?) OR (json_extract(json, '$.author.name') = ? AND cid > ?))",
340340+ )
341341+342342+ params
343343+ |> should.equal(["Alice", "Alice", "bafytest123"])
344344+}
345345+346346+/// Test building WHERE clause for multiple fields
347347+pub fn build_where_multi_field_test() {
348348+ let decoded = cursor.DecodedCursor(
349349+ field_values: ["Hello", "2025-01-15T12:00:00Z"],
350350+ cid: "bafytest123",
351351+ )
352352+353353+ let sort_by = Some([#("text", "desc"), #("createdAt", "desc")])
354354+355355+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False)
356356+357357+ // Multi-field: progressive equality checks
358358+ // (text < ?) OR (text = ? AND createdAt < ?) OR (text = ? AND createdAt = ? AND cid < ?)
359359+ sql
360360+ |> should.equal(
361361+ "((json_extract(json, '$.text') < ?) OR (json_extract(json, '$.text') = ? AND json_extract(json, '$.createdAt') < ?) OR (json_extract(json, '$.text') = ? AND json_extract(json, '$.createdAt') = ? AND cid < ?))",
362362+ )
363363+364364+ params
365365+ |> should.equal([
366366+ "Hello",
367367+ "Hello",
368368+ "2025-01-15T12:00:00Z",
369369+ "Hello",
370370+ "2025-01-15T12:00:00Z",
371371+ "bafytest123",
372372+ ])
373373+}
374374+375375+/// Test building WHERE clause for backward pagination (before)
376376+pub fn build_where_backward_test() {
377377+ let decoded = cursor.DecodedCursor(
378378+ field_values: ["2025-01-15 12:00:00"],
379379+ cid: "bafytest123",
380380+ )
381381+382382+ let sort_by = Some([#("indexed_at", "desc")])
383383+384384+ // is_before = True reverses the comparison operators
385385+ let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, True)
386386+387387+ // For before with DESC: indexed_at > cursor_value OR (indexed_at = cursor_value AND cid > cursor_cid)
388388+ sql
389389+ |> should.equal("((indexed_at > ?) OR (indexed_at = ? AND cid > ?))")
390390+391391+ params
392392+ |> should.equal([
393393+ "2025-01-15 12:00:00",
394394+ "2025-01-15 12:00:00",
395395+ "bafytest123",
396396+ ])
397397+}