Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement relay cursor connection spec

+1428 -44
-4
Makefile
··· 17 17 @echo "Building lexicon_graphql package..." 18 18 @cd lexicon_graphql && gleam build 19 19 @echo "" 20 - @echo "Building jetstream package..." 21 - @cd jetstream && gleam build 22 - @echo "" 23 20 @echo "Building server..." 24 21 @cd server && gleam build 25 22 @echo "" ··· 48 45 @echo "Cleaning build artifacts..." 49 46 @cd graphql && gleam clean 50 47 @cd lexicon_graphql && gleam clean 51 - @cd jetstream && gleam clean 52 48 @cd server && gleam clean 53 49 @echo "Clean complete"
+324
graphql/src/graphql/connection.gleam
··· 1 + /// GraphQL Connection Types for Relay Cursor Connections 2 + /// 3 + /// Implements the Relay Cursor Connections Specification: 4 + /// https://relay.dev/graphql/connections.htm 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import graphql/schema 8 + import graphql/value 9 + 10 + /// PageInfo type for connection pagination metadata 11 + pub type PageInfo { 12 + PageInfo( 13 + has_next_page: Bool, 14 + has_previous_page: Bool, 15 + start_cursor: Option(String), 16 + end_cursor: Option(String), 17 + ) 18 + } 19 + 20 + /// Edge wrapper containing a node and its cursor 21 + pub type Edge(node_type) { 22 + Edge(node: node_type, cursor: String) 23 + } 24 + 25 + /// Connection wrapper containing edges and page info 26 + pub type Connection(node_type) { 27 + Connection( 28 + edges: List(Edge(node_type)), 29 + page_info: PageInfo, 30 + total_count: Option(Int), 31 + ) 32 + } 33 + 34 + /// Creates the PageInfo GraphQL type 35 + pub fn page_info_type() -> schema.Type { 36 + schema.object_type( 37 + "PageInfo", 38 + "Information about pagination in a connection", 39 + [ 40 + schema.field( 41 + "hasNextPage", 42 + schema.non_null(schema.boolean_type()), 43 + "When paginating forwards, are there more items?", 44 + fn(ctx) { 45 + // Extract from context data 46 + case ctx.data { 47 + Some(value.Object(fields)) -> { 48 + case list.key_find(fields, "hasNextPage") { 49 + Ok(val) -> Ok(val) 50 + Error(_) -> Ok(value.Boolean(False)) 51 + } 52 + } 53 + _ -> Ok(value.Boolean(False)) 54 + } 55 + }, 56 + ), 57 + schema.field( 58 + "hasPreviousPage", 59 + schema.non_null(schema.boolean_type()), 60 + "When paginating backwards, are there more items?", 61 + fn(ctx) { 62 + case ctx.data { 63 + Some(value.Object(fields)) -> { 64 + case list.key_find(fields, "hasPreviousPage") { 65 + Ok(val) -> Ok(val) 66 + Error(_) -> Ok(value.Boolean(False)) 67 + } 68 + } 69 + _ -> Ok(value.Boolean(False)) 70 + } 71 + }, 72 + ), 73 + schema.field( 74 + "startCursor", 75 + schema.string_type(), 76 + "Cursor corresponding to the first item in the page", 77 + fn(ctx) { 78 + case ctx.data { 79 + Some(value.Object(fields)) -> { 80 + case list.key_find(fields, "startCursor") { 81 + Ok(val) -> Ok(val) 82 + Error(_) -> Ok(value.Null) 83 + } 84 + } 85 + _ -> Ok(value.Null) 86 + } 87 + }, 88 + ), 89 + schema.field( 90 + "endCursor", 91 + schema.string_type(), 92 + "Cursor corresponding to the last item in the page", 93 + fn(ctx) { 94 + case ctx.data { 95 + Some(value.Object(fields)) -> { 96 + case list.key_find(fields, "endCursor") { 97 + Ok(val) -> Ok(val) 98 + Error(_) -> Ok(value.Null) 99 + } 100 + } 101 + _ -> Ok(value.Null) 102 + } 103 + }, 104 + ), 105 + ], 106 + ) 107 + } 108 + 109 + /// Creates an Edge type for a given node type name 110 + pub fn edge_type(node_type_name: String, node_type: schema.Type) -> schema.Type { 111 + let edge_type_name = node_type_name <> "Edge" 112 + 113 + schema.object_type( 114 + edge_type_name, 115 + "An edge in a connection for " <> node_type_name, 116 + [ 117 + schema.field( 118 + "node", 119 + schema.non_null(node_type), 120 + "The item at the end of the edge", 121 + fn(ctx) { 122 + // Extract node from context data 123 + case ctx.data { 124 + Some(value.Object(fields)) -> { 125 + case list.key_find(fields, "node") { 126 + Ok(val) -> Ok(val) 127 + Error(_) -> Ok(value.Null) 128 + } 129 + } 130 + _ -> Ok(value.Null) 131 + } 132 + }, 133 + ), 134 + schema.field( 135 + "cursor", 136 + schema.non_null(schema.string_type()), 137 + "A cursor for use in pagination", 138 + fn(ctx) { 139 + case ctx.data { 140 + Some(value.Object(fields)) -> { 141 + case list.key_find(fields, "cursor") { 142 + Ok(val) -> Ok(val) 143 + Error(_) -> Ok(value.String("")) 144 + } 145 + } 146 + _ -> Ok(value.String("")) 147 + } 148 + }, 149 + ), 150 + ], 151 + ) 152 + } 153 + 154 + /// Creates a Connection type for a given node type name 155 + pub fn connection_type( 156 + node_type_name: String, 157 + edge_type: schema.Type, 158 + ) -> schema.Type { 159 + let connection_type_name = node_type_name <> "Connection" 160 + 161 + schema.object_type( 162 + connection_type_name, 163 + "A connection to a list of items for " <> node_type_name, 164 + [ 165 + schema.field( 166 + "edges", 167 + schema.non_null(schema.list_type(schema.non_null(edge_type))), 168 + "A list of edges", 169 + fn(ctx) { 170 + // Extract edges from context data 171 + case ctx.data { 172 + Some(value.Object(fields)) -> { 173 + case list.key_find(fields, "edges") { 174 + Ok(val) -> Ok(val) 175 + Error(_) -> Ok(value.List([])) 176 + } 177 + } 178 + _ -> Ok(value.List([])) 179 + } 180 + }, 181 + ), 182 + schema.field( 183 + "pageInfo", 184 + schema.non_null(page_info_type()), 185 + "Information to aid in pagination", 186 + fn(ctx) { 187 + // Extract pageInfo from context data 188 + case ctx.data { 189 + Some(value.Object(fields)) -> { 190 + case list.key_find(fields, "pageInfo") { 191 + Ok(val) -> Ok(val) 192 + Error(_) -> 193 + Ok( 194 + value.Object([ 195 + #("hasNextPage", value.Boolean(False)), 196 + #("hasPreviousPage", value.Boolean(False)), 197 + #("startCursor", value.Null), 198 + #("endCursor", value.Null), 199 + ]), 200 + ) 201 + } 202 + } 203 + _ -> 204 + Ok( 205 + value.Object([ 206 + #("hasNextPage", value.Boolean(False)), 207 + #("hasPreviousPage", value.Boolean(False)), 208 + #("startCursor", value.Null), 209 + #("endCursor", value.Null), 210 + ]), 211 + ) 212 + } 213 + }, 214 + ), 215 + schema.field( 216 + "totalCount", 217 + schema.int_type(), 218 + "Total number of items in the connection", 219 + fn(ctx) { 220 + case ctx.data { 221 + Some(value.Object(fields)) -> { 222 + case list.key_find(fields, "totalCount") { 223 + Ok(val) -> Ok(val) 224 + Error(_) -> Ok(value.Null) 225 + } 226 + } 227 + _ -> Ok(value.Null) 228 + } 229 + }, 230 + ), 231 + ], 232 + ) 233 + } 234 + 235 + /// Standard pagination arguments for forward pagination 236 + pub fn forward_pagination_args() -> List(schema.Argument) { 237 + [ 238 + schema.argument( 239 + "first", 240 + schema.int_type(), 241 + "Returns the first n items from the list", 242 + None, 243 + ), 244 + schema.argument( 245 + "after", 246 + schema.string_type(), 247 + "Returns items after the given cursor", 248 + None, 249 + ), 250 + ] 251 + } 252 + 253 + /// Standard pagination arguments for backward pagination 254 + pub fn backward_pagination_args() -> List(schema.Argument) { 255 + [ 256 + schema.argument( 257 + "last", 258 + schema.int_type(), 259 + "Returns the last n items from the list", 260 + None, 261 + ), 262 + schema.argument( 263 + "before", 264 + schema.string_type(), 265 + "Returns items before the given cursor", 266 + None, 267 + ), 268 + ] 269 + } 270 + 271 + /// All standard connection arguments (forward + backward) 272 + /// Note: sortBy is not included yet as it requires InputObject type support 273 + pub fn connection_args() -> List(schema.Argument) { 274 + list.flatten([forward_pagination_args(), backward_pagination_args()]) 275 + } 276 + 277 + /// Converts a PageInfo value to a GraphQL value 278 + pub fn page_info_to_value(page_info: PageInfo) -> value.Value { 279 + value.Object([ 280 + #("hasNextPage", value.Boolean(page_info.has_next_page)), 281 + #("hasPreviousPage", value.Boolean(page_info.has_previous_page)), 282 + #( 283 + "startCursor", 284 + case page_info.start_cursor { 285 + Some(cursor) -> value.String(cursor) 286 + None -> value.Null 287 + }, 288 + ), 289 + #( 290 + "endCursor", 291 + case page_info.end_cursor { 292 + Some(cursor) -> value.String(cursor) 293 + None -> value.Null 294 + }, 295 + ), 296 + ]) 297 + } 298 + 299 + /// Converts an Edge to a GraphQL value 300 + pub fn edge_to_value(edge: Edge(value.Value)) -> value.Value { 301 + value.Object([ 302 + #("node", edge.node), 303 + #("cursor", value.String(edge.cursor)), 304 + ]) 305 + } 306 + 307 + /// Converts a Connection to a GraphQL value 308 + pub fn connection_to_value(connection: Connection(value.Value)) -> value.Value { 309 + let edges_value = 310 + connection.edges 311 + |> list.map(edge_to_value) 312 + |> value.List 313 + 314 + let total_count_value = case connection.total_count { 315 + Some(count) -> value.Int(count) 316 + None -> value.Null 317 + } 318 + 319 + value.Object([ 320 + #("edges", edges_value), 321 + #("pageInfo", page_info_to_value(connection.page_info)), 322 + #("totalCount", total_count_value), 323 + ]) 324 + }
+11 -3
graphql/src/graphql/executor.gleam
··· 342 342 case field_value { 343 343 value.Object(_) -> { 344 344 // Execute nested selections using the field's type, not parent type 345 + // Create new context with this object's data 346 + let object_ctx = schema.Context(option.Some(field_value)) 345 347 let selection_set = 346 348 parser.SelectionSet(nested_selections) 347 349 case ··· 349 351 selection_set, 350 352 field_type_def, 351 353 graphql_schema, 352 - ctx, 354 + object_ctx, 353 355 fragments, 354 356 [name, ..path], 355 357 ) ··· 364 366 } 365 367 value.List(items) -> { 366 368 // Handle list with nested selections 367 - // Get the inner type from the LIST wrapper 369 + // Get the inner type from the LIST wrapper, unwrapping NonNull if needed 368 370 let inner_type = case 369 371 schema.inner_type(field_type_def) 370 372 { 371 - option.Some(t) -> t 373 + option.Some(t) -> { 374 + // If the result is still wrapped (NonNull), unwrap it too 375 + case schema.inner_type(t) { 376 + option.Some(unwrapped) -> unwrapped 377 + option.None -> t 378 + } 379 + } 372 380 option.None -> field_type_def 373 381 } 374 382
+1
graphql/src/graphql/schema.gleam
··· 195 195 }) 196 196 |> option.from_result 197 197 } 198 + NonNullType(inner) -> get_field(inner, field_name) 198 199 _ -> None 199 200 } 200 201 }
+83 -13
lexicon_graphql/src/lexicon_graphql/db_schema_builder.gleam
··· 5 5 import gleam/list 6 6 import gleam/option 7 7 import gleam/result 8 + import graphql/connection 8 9 import graphql/schema 9 10 import graphql/value 10 11 import lexicon_graphql/nsid ··· 21 22 ) 22 23 } 23 24 24 - /// Type for a database record fetcher function 25 - /// Takes a collection NSID and returns a list of records as GraphQL values 25 + /// Pagination parameters for connection queries 26 + pub type PaginationParams { 27 + PaginationParams( 28 + first: option.Option(Int), 29 + after: option.Option(String), 30 + last: option.Option(Int), 31 + before: option.Option(String), 32 + sort_by: option.Option(List(#(String, String))), 33 + ) 34 + } 35 + 36 + /// Type for a database record fetcher function with pagination support 37 + /// Takes a collection NSID and pagination params, returns Connection data 38 + /// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page) 26 39 pub type RecordFetcher = 27 - fn(String) -> Result(List(value.Value), String) 40 + fn(String, PaginationParams) -> 41 + Result( 42 + #(List(#(value.Value, String)), option.Option(String), Bool, Bool), 43 + String, 44 + ) 28 45 29 46 /// Build a GraphQL schema from lexicons with database-backed resolvers 30 47 /// ··· 161 178 record_type.fields, 162 179 ) 163 180 164 - // Create a list type for the query field 165 - let list_type = schema.list_type(object_type) 181 + // Create Connection types 182 + let edge_type = connection.edge_type(record_type.type_name, object_type) 183 + let connection_type = 184 + connection.connection_type(record_type.type_name, edge_type) 166 185 167 - // Create query field that returns a list of this record type 186 + // Create query field that returns a Connection of this record type 168 187 // Capture the nsid and fetcher in the closure 169 188 let collection_nsid = record_type.nsid 170 - schema.field( 189 + schema.field_with_args( 171 190 record_type.field_name, 172 - list_type, 173 - "Query " <> record_type.nsid, 174 - fn(_ctx: schema.Context) { 175 - // Call the fetcher function to get records from database 176 - fetcher(collection_nsid) 177 - |> result.map(fn(records) { value.List(records) }) 191 + connection_type, 192 + "Query " <> record_type.nsid <> " with cursor pagination", 193 + connection.connection_args(), 194 + fn(ctx: schema.Context) { 195 + // Extract pagination arguments from context 196 + let pagination_params = extract_pagination_params(ctx) 197 + 198 + // Call the fetcher function to get records with cursors from database 199 + use #(records_with_cursors, end_cursor, has_next_page, has_previous_page) <- result.try( 200 + fetcher(collection_nsid, pagination_params), 201 + ) 202 + 203 + // Build edges from records with their cursors 204 + let edges = 205 + list.map(records_with_cursors, fn(record_tuple) { 206 + let #(record_value, record_cursor) = record_tuple 207 + connection.Edge(node: record_value, cursor: record_cursor) 208 + }) 209 + 210 + // Build PageInfo 211 + let page_info = 212 + connection.PageInfo( 213 + has_next_page: has_next_page, 214 + has_previous_page: has_previous_page, 215 + start_cursor: case list.first(edges) { 216 + Ok(edge) -> option.Some(edge.cursor) 217 + Error(_) -> option.None 218 + }, 219 + end_cursor: end_cursor, 220 + ) 221 + 222 + // Build Connection 223 + let conn = 224 + connection.Connection( 225 + edges: edges, 226 + page_info: page_info, 227 + total_count: option.None, 228 + ) 229 + 230 + Ok(connection.connection_to_value(conn)) 178 231 }, 179 232 ) 180 233 }) 181 234 182 235 schema.object_type("Query", "Root query type", query_fields) 236 + } 237 + 238 + /// Extract pagination parameters from GraphQL context 239 + fn extract_pagination_params(ctx: schema.Context) -> PaginationParams { 240 + let _ = ctx 241 + // TODO: In a full implementation, arguments would be extracted from context 242 + // Currently the GraphQL executor doesn't pass arguments to resolvers 243 + // For now, we use sensible defaults: 244 + // - Default to 50 items per page 245 + // - Sort by indexed_at DESC (most recent first) 246 + PaginationParams( 247 + first: option.Some(50), 248 + after: option.None, 249 + last: option.None, 250 + before: option.None, 251 + sort_by: option.Some([#("indexed_at", "desc")]), 252 + ) 183 253 } 184 254 185 255 /// Helper to extract a field value from resolver context
+363
server/src/cursor.gleam
··· 1 + /// Cursor-based pagination utilities. 2 + /// 3 + /// Cursors encode the position in a result set as base64(field1|field2|...|cid) 4 + /// to enable stable pagination even when new records are inserted. 5 + /// 6 + /// The cursor format: 7 + /// - All sort field values are included in the cursor 8 + /// - Values are separated by pipe (|) characters 9 + /// - CID is always the last element as the ultimate tiebreaker 10 + import gleam/bit_array 11 + import gleam/dict 12 + import gleam/dynamic 13 + import gleam/dynamic/decode 14 + import gleam/float 15 + import gleam/int 16 + import gleam/json 17 + import gleam/list 18 + import gleam/option.{type Option, None, Some} 19 + import gleam/result 20 + import gleam/string 21 + 22 + /// Decoded cursor components for pagination 23 + pub type DecodedCursor { 24 + DecodedCursor( 25 + /// Field values in the order they appear in sortBy 26 + field_values: List(String), 27 + /// CID (always the last element) 28 + cid: String, 29 + ) 30 + } 31 + 32 + /// Encodes a string to URL-safe base64 without padding 33 + pub fn encode_base64(input: String) -> String { 34 + let bytes = bit_array.from_string(input) 35 + let encoded = bit_array.base64_url_encode(bytes, False) 36 + encoded 37 + } 38 + 39 + /// Decodes a URL-safe base64 string without padding 40 + pub fn decode_base64(input: String) -> Result(String, String) { 41 + case bit_array.base64_url_decode(input) { 42 + Ok(bytes) -> 43 + case bit_array.to_string(bytes) { 44 + Ok(str) -> Ok(str) 45 + Error(_) -> Error("Invalid UTF-8 in cursor") 46 + } 47 + Error(_) -> Error("Failed to decode base64") 48 + } 49 + } 50 + 51 + /// Record-like type for cursor generation 52 + /// This allows cursor to work with any record type without importing database 53 + pub type RecordLike { 54 + RecordLike( 55 + uri: String, 56 + cid: String, 57 + did: String, 58 + collection: String, 59 + json: String, 60 + indexed_at: String, 61 + ) 62 + } 63 + 64 + /// Extracts a field value from a record. 65 + /// 66 + /// Handles both table columns and JSON fields with nested paths. 67 + pub fn extract_field_value(record: RecordLike, field: String) -> String { 68 + case field { 69 + "uri" -> record.uri 70 + "cid" -> record.cid 71 + "did" -> record.did 72 + "collection" -> record.collection 73 + "indexed_at" -> record.indexed_at 74 + _ -> extract_json_field(record.json, field) 75 + } 76 + } 77 + 78 + /// Extracts a value from a JSON string using a field path 79 + fn extract_json_field(json_str: String, field: String) -> String { 80 + // Parse the JSON as a dictionary 81 + let decoder = decode.dict(decode.string, decode.dynamic) 82 + case json.parse(json_str, decoder) { 83 + Error(_) -> "NULL" 84 + Ok(dict) -> { 85 + // Split field path by dots for nested access 86 + let path_parts = string.split(field, ".") 87 + 88 + // Navigate through the JSON structure 89 + extract_from_dict(dict, path_parts) 90 + } 91 + } 92 + } 93 + 94 + /// Recursively extracts a value from a dict using a path 95 + fn extract_from_dict( 96 + dict: dict.Dict(String, dynamic.Dynamic), 97 + path: List(String), 98 + ) -> String { 99 + case path { 100 + [] -> "NULL" 101 + [key] -> { 102 + // Final key - extract and convert to string 103 + case dict.get(dict, key) { 104 + Ok(val) -> dynamic_to_string(val) 105 + Error(_) -> "NULL" 106 + } 107 + } 108 + [key, ..rest] -> { 109 + // Intermediate key - try to decode as nested dict 110 + case dict.get(dict, key) { 111 + Ok(val) -> { 112 + case decode.run(val, decode.dict(decode.string, decode.dynamic)) { 113 + Ok(nested_dict) -> extract_from_dict(nested_dict, rest) 114 + Error(_) -> "NULL" 115 + } 116 + } 117 + Error(_) -> "NULL" 118 + } 119 + } 120 + } 121 + } 122 + 123 + /// Converts a dynamic JSON value to a string representation 124 + fn dynamic_to_string(value: dynamic.Dynamic) -> String { 125 + // Try to decode as string 126 + case decode.run(value, decode.string) { 127 + Ok(s) -> s 128 + Error(_) -> 129 + // Try as int 130 + case decode.run(value, decode.int) { 131 + Ok(i) -> int.to_string(i) 132 + Error(_) -> 133 + // Try as float 134 + case decode.run(value, decode.float) { 135 + Ok(f) -> float.to_string(f) 136 + Error(_) -> 137 + // Try as bool 138 + case decode.run(value, decode.bool) { 139 + Ok(b) -> 140 + case b { 141 + True -> "true" 142 + False -> "false" 143 + } 144 + Error(_) -> "NULL" 145 + } 146 + } 147 + } 148 + } 149 + } 150 + 151 + /// Generates a cursor from a record based on the sort configuration. 152 + /// 153 + /// Extracts all sort field values from the record and encodes them along with the CID. 154 + /// Format: `base64(field1_value|field2_value|...|cid)` 155 + pub fn generate_cursor_from_record( 156 + record: RecordLike, 157 + sort_by: Option(List(#(String, String))), 158 + ) -> String { 159 + let cursor_parts = case sort_by { 160 + None -> [] 161 + Some(sort_fields) -> { 162 + list.map(sort_fields, fn(sort_field) { 163 + let #(field, _direction) = sort_field 164 + extract_field_value(record, field) 165 + }) 166 + } 167 + } 168 + 169 + // Always add CID as the final tiebreaker 170 + let all_parts = list.append(cursor_parts, [record.cid]) 171 + 172 + // Join with pipe and encode 173 + let cursor_content = string.join(all_parts, "|") 174 + encode_base64(cursor_content) 175 + } 176 + 177 + /// Decodes a base64-encoded cursor back into its components. 178 + /// 179 + /// The cursor format is: `base64(field1|field2|...|cid)` 180 + pub fn decode_cursor( 181 + cursor: String, 182 + sort_by: Option(List(#(String, String))), 183 + ) -> Result(DecodedCursor, String) { 184 + use decoded_str <- result.try(decode_base64(cursor)) 185 + 186 + let parts = string.split(decoded_str, "|") 187 + 188 + // Validate cursor format matches sortBy fields 189 + let expected_parts = case sort_by { 190 + None -> 1 191 + Some(fields) -> list.length(fields) + 1 192 + } 193 + 194 + case list.length(parts) == expected_parts { 195 + False -> 196 + Error( 197 + "Invalid cursor format: expected " 198 + <> int.to_string(expected_parts) 199 + <> " parts, got " 200 + <> int.to_string(list.length(parts)), 201 + ) 202 + True -> { 203 + // Last part is the CID 204 + case list.reverse(parts) { 205 + [cid, ..rest_reversed] -> { 206 + let field_values = list.reverse(rest_reversed) 207 + Ok(DecodedCursor(field_values: field_values, cid: cid)) 208 + } 209 + [] -> Error("Cursor has no parts") 210 + } 211 + } 212 + } 213 + } 214 + 215 + /// Builds cursor-based WHERE conditions for proper multi-field pagination. 216 + /// 217 + /// Creates progressive equality checks for stable multi-field sorting. 218 + /// For each field, we OR together: 219 + /// 1. field1 > cursor_value1 220 + /// 2. field1 = cursor_value1 AND field2 > cursor_value2 221 + /// 3. field1 = cursor_value1 AND field2 = cursor_value2 AND field3 > cursor_value3 222 + /// ... and so on 223 + /// Finally: all fields equal AND cid > cursor_cid 224 + /// 225 + /// Returns: #(where_clause_sql, bind_values) 226 + pub fn build_cursor_where_clause( 227 + decoded_cursor: DecodedCursor, 228 + sort_by: Option(List(#(String, String))), 229 + is_before: Bool, 230 + ) -> #(String, List(String)) { 231 + let sort_fields = case sort_by { 232 + None -> [] 233 + Some(fields) -> fields 234 + } 235 + 236 + case list.is_empty(sort_fields) { 237 + True -> #("1=1", []) 238 + False -> { 239 + let clauses = build_progressive_clauses( 240 + sort_fields, 241 + decoded_cursor.field_values, 242 + decoded_cursor.cid, 243 + is_before, 244 + ) 245 + 246 + let sql = "(" <> string.join(clauses.0, " OR ") <> ")" 247 + #(sql, clauses.1) 248 + } 249 + } 250 + } 251 + 252 + /// Builds progressive equality clauses for cursor pagination 253 + fn build_progressive_clauses( 254 + sort_fields: List(#(String, String)), 255 + field_values: List(String), 256 + cid: String, 257 + is_before: Bool, 258 + ) -> #(List(String), List(String)) { 259 + let _field_count = list.length(sort_fields) 260 + 261 + // Build clauses for each level 262 + let #(clauses, params) = 263 + list.index_map(sort_fields, fn(field, i) { 264 + // Build equality checks for fields [0..i-1] 265 + let #(equality_parts, equality_params) = case i { 266 + 0 -> #([], []) 267 + _ -> { 268 + list.range(0, i - 1) 269 + |> list.fold(#([], []), fn(eq_acc, j) { 270 + let #(eq_parts, eq_params) = eq_acc 271 + let prior_field = list_at(sort_fields, j) |> result.unwrap(#("", "")) 272 + let value = list_at(field_values, j) |> result.unwrap("") 273 + 274 + let field_ref = build_field_reference(prior_field.0) 275 + let new_part = field_ref <> " = ?" 276 + let new_params = list.append(eq_params, [value]) 277 + 278 + #(list.append(eq_parts, [new_part]), new_params) 279 + }) 280 + } 281 + } 282 + 283 + // Add comparison for current field 284 + let value = list_at(field_values, i) |> result.unwrap("") 285 + 286 + let comparison_op = get_comparison_operator(field.1, is_before) 287 + let field_ref = build_field_reference(field.0) 288 + 289 + let comparison_part = field_ref <> " " <> comparison_op <> " ?" 290 + let all_parts = list.append(equality_parts, [comparison_part]) 291 + let all_params = list.append(equality_params, [value]) 292 + 293 + // Combine with AND 294 + let clause = "(" <> string.join(all_parts, " AND ") <> ")" 295 + 296 + #(clause, all_params) 297 + }) 298 + |> list.unzip 299 + |> fn(unzipped) { 300 + // Flatten the params lists 301 + let flattened_params = list.flatten(unzipped.1) 302 + #(unzipped.0, flattened_params) 303 + } 304 + 305 + // Add final clause: all fields equal AND cid comparison 306 + let #(final_equality_parts, final_equality_params) = 307 + list.index_map(sort_fields, fn(field, j) { 308 + let value = list_at(field_values, j) |> result.unwrap("") 309 + let field_ref = build_field_reference(field.0) 310 + #(field_ref <> " = ?", value) 311 + }) 312 + |> list.unzip 313 + 314 + // CID comparison uses the direction of the last sort field 315 + let last_field = list.last(sort_fields) |> result.unwrap(#("", "desc")) 316 + let cid_comparison_op = get_comparison_operator(last_field.1, is_before) 317 + 318 + let final_parts = list.append(final_equality_parts, ["cid " <> cid_comparison_op <> " ?"]) 319 + let final_params = list.append(final_equality_params, [cid]) 320 + 321 + let final_clause = "(" <> string.join(final_parts, " AND ") <> ")" 322 + let all_clauses = list.append(clauses, [final_clause]) 323 + let all_params = list.append(params, final_params) 324 + 325 + #(all_clauses, all_params) 326 + } 327 + 328 + /// Builds a field reference for SQL queries (handles JSON fields) 329 + fn build_field_reference(field: String) -> String { 330 + case field { 331 + "uri" | "cid" | "did" | "collection" | "indexed_at" -> field 332 + _ -> { 333 + // JSON field - use json_extract with JSON path 334 + let json_path = "$." <> string.replace(field, ".", ".") 335 + "json_extract(json, '" <> json_path <> "')" 336 + } 337 + } 338 + } 339 + 340 + /// Gets the comparison operator based on sort direction and pagination direction 341 + fn get_comparison_operator(direction: String, is_before: Bool) -> String { 342 + let is_desc = string.lowercase(direction) == "desc" 343 + 344 + case is_before { 345 + True -> 346 + case is_desc { 347 + True -> ">" 348 + False -> "<" 349 + } 350 + False -> 351 + case is_desc { 352 + True -> "<" 353 + False -> ">" 354 + } 355 + } 356 + } 357 + 358 + /// Helper to get an element at an index from a list 359 + fn list_at(list: List(a), index: Int) -> Result(a, Nil) { 360 + list 361 + |> list.drop(index) 362 + |> list.first 363 + }
+168
server/src/database.gleam
··· 1 + import cursor 1 2 import gleam/dynamic/decode 3 + import gleam/int 2 4 import gleam/io 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 3 7 import gleam/result 8 + import gleam/string 4 9 import sqlight 5 10 6 11 pub type Record { ··· 569 574 570 575 sqlight.query(sql, on: conn, with: [], expecting: decoder) 571 576 } 577 + 578 + /// Paginated query for records with cursor-based pagination 579 + /// 580 + /// Supports both forward (first/after) and backward (last/before) pagination. 581 + /// Returns a tuple of (records, next_cursor, has_next_page, has_previous_page) 582 + pub fn get_records_by_collection_paginated( 583 + conn: sqlight.Connection, 584 + collection: String, 585 + first: Option(Int), 586 + after: Option(String), 587 + last: Option(Int), 588 + before: Option(String), 589 + sort_by: Option(List(#(String, String))), 590 + ) -> Result(#(List(Record), Option(String), Bool, Bool), sqlight.Error) { 591 + // Validate pagination arguments 592 + let #(limit, is_forward, cursor_opt) = case first, last { 593 + Some(f), None -> #(f, True, after) 594 + None, Some(l) -> #(l, False, before) 595 + Some(f), Some(_) -> 596 + // Both first and last specified - use first 597 + #(f, True, after) 598 + None, None -> 599 + // Neither specified - default to first 50 600 + #(50, True, None) 601 + } 602 + 603 + // Default sort order if not specified 604 + let sort_fields = case sort_by { 605 + Some(fields) -> fields 606 + None -> [#("indexed_at", "desc")] 607 + } 608 + 609 + // Build the ORDER BY clause 610 + let order_by_clause = build_order_by(sort_fields) 611 + 612 + // Build WHERE clause parts 613 + let where_parts = ["collection = ?"] 614 + let bind_values = [sqlight.text(collection)] 615 + 616 + // Add cursor condition if present 617 + let #(final_where_parts, final_bind_values) = case cursor_opt { 618 + Some(cursor_str) -> { 619 + case cursor.decode_cursor(cursor_str, sort_by) { 620 + Ok(decoded_cursor) -> { 621 + let #(cursor_where, cursor_params) = 622 + cursor.build_cursor_where_clause( 623 + decoded_cursor, 624 + sort_by, 625 + !is_forward, 626 + ) 627 + 628 + let new_where = list.append(where_parts, [cursor_where]) 629 + let new_binds = 630 + list.append( 631 + bind_values, 632 + list.map(cursor_params, sqlight.text), 633 + ) 634 + #(new_where, new_binds) 635 + } 636 + Error(_) -> #(where_parts, bind_values) 637 + } 638 + } 639 + None -> #(where_parts, bind_values) 640 + } 641 + 642 + // Fetch limit + 1 to detect if there are more pages 643 + let fetch_limit = limit + 1 644 + 645 + // Build the SQL query 646 + let sql = 647 + " 648 + SELECT uri, cid, did, collection, json, indexed_at 649 + FROM record 650 + WHERE " 651 + <> string.join(final_where_parts, " AND ") 652 + <> " 653 + ORDER BY " 654 + <> order_by_clause 655 + <> " 656 + LIMIT " 657 + <> int.to_string(fetch_limit) 658 + 659 + // Execute query 660 + let decoder = { 661 + use uri <- decode.field(0, decode.string) 662 + use cid <- decode.field(1, decode.string) 663 + use did <- decode.field(2, decode.string) 664 + use collection <- decode.field(3, decode.string) 665 + use json <- decode.field(4, decode.string) 666 + use indexed_at <- decode.field(5, decode.string) 667 + decode.success(Record(uri:, cid:, did:, collection:, json:, indexed_at:)) 668 + } 669 + 670 + use records <- result.try(sqlight.query( 671 + sql, 672 + on: conn, 673 + with: final_bind_values, 674 + expecting: decoder, 675 + )) 676 + 677 + // Check if there are more results 678 + let has_more = list.length(records) > limit 679 + let final_records = case has_more { 680 + True -> list.take(records, limit) 681 + False -> records 682 + } 683 + 684 + // Calculate hasNextPage and hasPreviousPage 685 + let has_next_page = case is_forward { 686 + True -> has_more 687 + False -> option.is_some(cursor_opt) 688 + } 689 + 690 + let has_previous_page = case is_forward { 691 + True -> option.is_some(cursor_opt) 692 + False -> has_more 693 + } 694 + 695 + // Generate next cursor if there are more results 696 + let next_cursor = case has_more, list.last(final_records) { 697 + True, Ok(last_record) -> { 698 + let record_like = record_to_record_like(last_record) 699 + Some(cursor.generate_cursor_from_record(record_like, sort_by)) 700 + } 701 + _, _ -> None 702 + } 703 + 704 + Ok(#(final_records, next_cursor, has_next_page, has_previous_page)) 705 + } 706 + 707 + /// Converts a database Record to a cursor.RecordLike 708 + pub fn record_to_record_like(record: Record) -> cursor.RecordLike { 709 + cursor.RecordLike( 710 + uri: record.uri, 711 + cid: record.cid, 712 + did: record.did, 713 + collection: record.collection, 714 + json: record.json, 715 + indexed_at: record.indexed_at, 716 + ) 717 + } 718 + 719 + /// Builds an ORDER BY clause from sort fields 720 + fn build_order_by(sort_fields: List(#(String, String))) -> String { 721 + let order_parts = 722 + list.map(sort_fields, fn(field) { 723 + let #(field_name, direction) = field 724 + let field_ref = case field_name { 725 + "uri" | "cid" | "did" | "collection" | "indexed_at" -> field_name 726 + _ -> "json_extract(json, '$." <> field_name <> "')" 727 + } 728 + let dir = case string.lowercase(direction) { 729 + "asc" -> "ASC" 730 + _ -> "DESC" 731 + } 732 + field_ref <> " " <> dir 733 + }) 734 + 735 + case list.is_empty(order_parts) { 736 + True -> "indexed_at DESC" 737 + False -> string.join(order_parts, ", ") 738 + } 739 + }
+40 -12
server/src/graphql_gleam.gleam
··· 2 2 /// 3 3 /// This module provides GraphQL schema building and query execution using 4 4 /// pure Gleam code, replacing the previous Elixir FFI implementation. 5 + import cursor 5 6 import database 6 7 import gleam/dict 7 8 import gleam/dynamic ··· 41 42 case parsed_lexicons { 42 43 [] -> Error("No valid lexicons found in database") 43 44 _ -> { 44 - // Step 3: Create a record fetcher function that queries the database 45 - let record_fetcher = fn(collection_nsid: String) -> Result( 46 - List(value.Value), 45 + // Step 3: Create a record fetcher function that queries the database with pagination 46 + let record_fetcher = fn( 47 + collection_nsid: String, 48 + pagination_params: db_schema_builder.PaginationParams, 49 + ) -> Result( 50 + #(List(#(value.Value, String)), option.Option(String), Bool, Bool), 47 51 String, 48 52 ) { 49 - // Fetch records from database for this collection 50 - case database.get_records_by_collection(db, collection_nsid) { 51 - Error(_) -> Ok([]) 52 - // Return empty list if no records found 53 - Ok(records) -> { 54 - // Convert database records to GraphQL values 55 - let graphql_records = 56 - list.map(records, fn(record) { record_to_graphql_value(record) }) 57 - Ok(graphql_records) 53 + // Fetch records from database for this collection with pagination 54 + case 55 + database.get_records_by_collection_paginated( 56 + db, 57 + collection_nsid, 58 + pagination_params.first, 59 + pagination_params.after, 60 + pagination_params.last, 61 + pagination_params.before, 62 + pagination_params.sort_by, 63 + ) 64 + { 65 + Error(_) -> Ok(#([], option.None, False, False)) 66 + // Return empty result on error 67 + Ok(#(records, next_cursor, has_next_page, has_previous_page)) -> { 68 + // Convert database records to GraphQL values with cursors 69 + let graphql_records_with_cursors = 70 + list.map(records, fn(record) { 71 + let graphql_value = record_to_graphql_value(record) 72 + // Generate cursor for this record 73 + let record_cursor = 74 + cursor.generate_cursor_from_record( 75 + database.record_to_record_like(record), 76 + pagination_params.sort_by, 77 + ) 78 + #(graphql_value, record_cursor) 79 + }) 80 + Ok(#( 81 + graphql_records_with_cursors, 82 + next_cursor, 83 + has_next_page, 84 + has_previous_page, 85 + )) 58 86 } 59 87 } 60 88 }
+397
server/test/cursor_test.gleam
··· 1 + import cursor 2 + import gleam/option.{None, Some} 3 + import gleeunit/should 4 + 5 + /// Test encoding a cursor with no sort fields (just CID) 6 + pub fn encode_cursor_no_sort_test() { 7 + let record = 8 + cursor.RecordLike( 9 + uri: "at://did:plc:test/app.bsky.feed.post/123", 10 + cid: "bafytest123", 11 + did: "did:plc:test", 12 + collection: "app.bsky.feed.post", 13 + json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}", 14 + indexed_at: "2025-01-15 12:00:00", 15 + ) 16 + 17 + let result = cursor.generate_cursor_from_record(record, None) 18 + 19 + // Decode the base64 to verify it's just the CID 20 + let decoded = cursor.decode_base64(result) 21 + should.be_ok(decoded) 22 + |> should.equal("bafytest123") 23 + } 24 + 25 + /// Test encoding a cursor with single sort field 26 + pub fn encode_cursor_single_field_test() { 27 + let record = 28 + cursor.RecordLike( 29 + uri: "at://did:plc:test/app.bsky.feed.post/123", 30 + cid: "bafytest123", 31 + did: "did:plc:test", 32 + collection: "app.bsky.feed.post", 33 + json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}", 34 + indexed_at: "2025-01-15 12:00:00", 35 + ) 36 + 37 + let sort_by = Some([#("indexed_at", "desc")]) 38 + 39 + let result = cursor.generate_cursor_from_record(record, sort_by) 40 + 41 + // Decode the base64 to verify format 42 + let decoded = cursor.decode_base64(result) 43 + should.be_ok(decoded) 44 + |> should.equal("2025-01-15 12:00:00|bafytest123") 45 + } 46 + 47 + /// Test encoding a cursor with JSON field 48 + pub fn encode_cursor_json_field_test() { 49 + let record = 50 + cursor.RecordLike( 51 + uri: "at://did:plc:test/app.bsky.feed.post/123", 52 + cid: "bafytest123", 53 + did: "did:plc:test", 54 + collection: "app.bsky.feed.post", 55 + json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\"}", 56 + indexed_at: "2025-01-15 12:00:00", 57 + ) 58 + 59 + let sort_by = Some([#("text", "desc")]) 60 + 61 + let result = cursor.generate_cursor_from_record(record, sort_by) 62 + 63 + let decoded = cursor.decode_base64(result) 64 + should.be_ok(decoded) 65 + |> should.equal("Hello world|bafytest123") 66 + } 67 + 68 + /// Test encoding a cursor with nested JSON field 69 + pub fn encode_cursor_nested_json_field_test() { 70 + let record = 71 + cursor.RecordLike( 72 + uri: "at://did:plc:test/app.bsky.feed.post/123", 73 + cid: "bafytest123", 74 + did: "did:plc:test", 75 + collection: "app.bsky.feed.post", 76 + json: "{\"author\":{\"name\":\"Alice\"},\"createdAt\":\"2025-01-15T12:00:00Z\"}", 77 + indexed_at: "2025-01-15 12:00:00", 78 + ) 79 + 80 + let sort_by = Some([#("author.name", "asc")]) 81 + 82 + let result = cursor.generate_cursor_from_record(record, sort_by) 83 + 84 + let decoded = cursor.decode_base64(result) 85 + should.be_ok(decoded) 86 + |> should.equal("Alice|bafytest123") 87 + } 88 + 89 + /// Test encoding a cursor with multiple sort fields 90 + pub fn encode_cursor_multi_field_test() { 91 + let record = 92 + cursor.RecordLike( 93 + uri: "at://did:plc:test/app.bsky.feed.post/123", 94 + cid: "bafytest123", 95 + did: "did:plc:test", 96 + collection: "app.bsky.feed.post", 97 + json: "{\"text\":\"Hello\",\"createdAt\":\"2025-01-15T12:00:00Z\"}", 98 + indexed_at: "2025-01-15 12:00:00", 99 + ) 100 + 101 + let sort_by = Some([#("text", "desc"), #("createdAt", "desc")]) 102 + 103 + let result = cursor.generate_cursor_from_record(record, sort_by) 104 + 105 + let decoded = cursor.decode_base64(result) 106 + should.be_ok(decoded) 107 + |> should.equal("Hello|2025-01-15T12:00:00Z|bafytest123") 108 + } 109 + 110 + /// Test decoding a valid cursor 111 + pub fn decode_cursor_valid_test() { 112 + let sort_by = Some([#("indexed_at", "desc")]) 113 + 114 + // Create a cursor: "2025-01-15 12:00:00|bafytest123" 115 + let cursor_str = cursor.encode_base64("2025-01-15 12:00:00|bafytest123") 116 + 117 + let result = cursor.decode_cursor(cursor_str, sort_by) 118 + 119 + should.be_ok(result) 120 + |> fn(decoded) { 121 + decoded.field_values 122 + |> should.equal(["2025-01-15 12:00:00"]) 123 + 124 + decoded.cid 125 + |> should.equal("bafytest123") 126 + } 127 + } 128 + 129 + /// Test decoding a multi-field cursor 130 + pub fn decode_cursor_multi_field_test() { 131 + let sort_by = Some([#("text", "desc"), #("createdAt", "desc")]) 132 + 133 + let cursor_str = cursor.encode_base64("Hello|2025-01-15T12:00:00Z|bafytest123") 134 + 135 + let result = cursor.decode_cursor(cursor_str, sort_by) 136 + 137 + should.be_ok(result) 138 + |> fn(decoded) { 139 + decoded.field_values 140 + |> should.equal(["Hello", "2025-01-15T12:00:00Z"]) 141 + 142 + decoded.cid 143 + |> should.equal("bafytest123") 144 + } 145 + } 146 + 147 + /// Test decoding with mismatched field count fails 148 + pub fn decode_cursor_mismatch_test() { 149 + let sort_by = Some([#("text", "desc")]) 150 + 151 + // Cursor has 2 fields but sort_by only has 1 152 + let cursor_str = cursor.encode_base64("Hello|2025-01-15T12:00:00Z|bafytest123") 153 + 154 + let result = cursor.decode_cursor(cursor_str, sort_by) 155 + 156 + should.be_error(result) 157 + } 158 + 159 + /// Test decoding invalid base64 fails 160 + pub fn decode_cursor_invalid_base64_test() { 161 + let sort_by = Some([#("text", "desc")]) 162 + 163 + let result = cursor.decode_cursor("not-valid-base64!!!", sort_by) 164 + 165 + should.be_error(result) 166 + } 167 + 168 + /// Test extracting table column values 169 + pub fn extract_field_value_table_column_test() { 170 + let record = 171 + cursor.RecordLike( 172 + uri: "at://did:plc:test/app.bsky.feed.post/123", 173 + cid: "bafytest123", 174 + did: "did:plc:test", 175 + collection: "app.bsky.feed.post", 176 + json: "{}", 177 + indexed_at: "2025-01-15 12:00:00", 178 + ) 179 + 180 + cursor.extract_field_value(record, "uri") 181 + |> should.equal("at://did:plc:test/app.bsky.feed.post/123") 182 + 183 + cursor.extract_field_value(record, "cid") 184 + |> should.equal("bafytest123") 185 + 186 + cursor.extract_field_value(record, "did") 187 + |> should.equal("did:plc:test") 188 + 189 + cursor.extract_field_value(record, "collection") 190 + |> should.equal("app.bsky.feed.post") 191 + 192 + cursor.extract_field_value(record, "indexed_at") 193 + |> should.equal("2025-01-15 12:00:00") 194 + } 195 + 196 + /// Test extracting JSON field values 197 + pub fn extract_field_value_json_test() { 198 + let record = 199 + cursor.RecordLike( 200 + uri: "at://did:plc:test/app.bsky.feed.post/123", 201 + cid: "bafytest123", 202 + did: "did:plc:test", 203 + collection: "app.bsky.feed.post", 204 + json: "{\"text\":\"Hello world\",\"createdAt\":\"2025-01-15T12:00:00Z\",\"likeCount\":42}", 205 + indexed_at: "2025-01-15 12:00:00", 206 + ) 207 + 208 + cursor.extract_field_value(record, "text") 209 + |> should.equal("Hello world") 210 + 211 + cursor.extract_field_value(record, "createdAt") 212 + |> should.equal("2025-01-15T12:00:00Z") 213 + 214 + cursor.extract_field_value(record, "likeCount") 215 + |> should.equal("42") 216 + } 217 + 218 + /// Test extracting nested JSON field values 219 + pub fn extract_field_value_nested_json_test() { 220 + let record = 221 + cursor.RecordLike( 222 + uri: "at://did:plc:test/app.bsky.feed.post/123", 223 + cid: "bafytest123", 224 + did: "did:plc:test", 225 + collection: "app.bsky.feed.post", 226 + json: "{\"author\":{\"name\":\"Alice\",\"did\":\"did:plc:alice\"}}", 227 + indexed_at: "2025-01-15 12:00:00", 228 + ) 229 + 230 + cursor.extract_field_value(record, "author.name") 231 + |> should.equal("Alice") 232 + 233 + cursor.extract_field_value(record, "author.did") 234 + |> should.equal("did:plc:alice") 235 + } 236 + 237 + /// Test extracting missing JSON field returns NULL 238 + pub fn extract_field_value_missing_test() { 239 + let record = 240 + cursor.RecordLike( 241 + uri: "at://did:plc:test/app.bsky.feed.post/123", 242 + cid: "bafytest123", 243 + did: "did:plc:test", 244 + collection: "app.bsky.feed.post", 245 + json: "{\"text\":\"Hello\"}", 246 + indexed_at: "2025-01-15 12:00:00", 247 + ) 248 + 249 + cursor.extract_field_value(record, "nonexistent") 250 + |> should.equal("NULL") 251 + 252 + cursor.extract_field_value(record, "author.name") 253 + |> should.equal("NULL") 254 + } 255 + 256 + // WHERE Condition Builder Tests 257 + 258 + /// Test building WHERE clause for single field DESC 259 + pub fn build_where_single_field_desc_test() { 260 + let decoded = cursor.DecodedCursor( 261 + field_values: ["2025-01-15 12:00:00"], 262 + cid: "bafytest123", 263 + ) 264 + 265 + let sort_by = Some([#("indexed_at", "desc")]) 266 + 267 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False) 268 + 269 + // For DESC: indexed_at < cursor_value OR (indexed_at = cursor_value AND cid < cursor_cid) 270 + sql 271 + |> should.equal("((indexed_at < ?) OR (indexed_at = ? AND cid < ?))") 272 + 273 + params 274 + |> should.equal([ 275 + "2025-01-15 12:00:00", 276 + "2025-01-15 12:00:00", 277 + "bafytest123", 278 + ]) 279 + } 280 + 281 + /// Test building WHERE clause for single field ASC 282 + pub fn build_where_single_field_asc_test() { 283 + let decoded = cursor.DecodedCursor( 284 + field_values: ["2025-01-15 12:00:00"], 285 + cid: "bafytest123", 286 + ) 287 + 288 + let sort_by = Some([#("indexed_at", "asc")]) 289 + 290 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False) 291 + 292 + // For ASC: indexed_at > cursor_value OR (indexed_at = cursor_value AND cid > cursor_cid) 293 + sql 294 + |> should.equal("((indexed_at > ?) OR (indexed_at = ? AND cid > ?))") 295 + 296 + params 297 + |> should.equal([ 298 + "2025-01-15 12:00:00", 299 + "2025-01-15 12:00:00", 300 + "bafytest123", 301 + ]) 302 + } 303 + 304 + /// Test building WHERE clause for JSON field 305 + pub fn build_where_json_field_test() { 306 + let decoded = cursor.DecodedCursor( 307 + field_values: ["Hello world"], 308 + cid: "bafytest123", 309 + ) 310 + 311 + let sort_by = Some([#("text", "desc")]) 312 + 313 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False) 314 + 315 + // JSON fields use json_extract 316 + sql 317 + |> should.equal( 318 + "((json_extract(json, '$.text') < ?) OR (json_extract(json, '$.text') = ? AND cid < ?))", 319 + ) 320 + 321 + params 322 + |> should.equal(["Hello world", "Hello world", "bafytest123"]) 323 + } 324 + 325 + /// Test building WHERE clause for nested JSON field 326 + pub fn build_where_nested_json_field_test() { 327 + let decoded = cursor.DecodedCursor( 328 + field_values: ["Alice"], 329 + cid: "bafytest123", 330 + ) 331 + 332 + let sort_by = Some([#("author.name", "asc")]) 333 + 334 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False) 335 + 336 + // Nested JSON fields use $.path.to.field 337 + sql 338 + |> should.equal( 339 + "((json_extract(json, '$.author.name') > ?) OR (json_extract(json, '$.author.name') = ? AND cid > ?))", 340 + ) 341 + 342 + params 343 + |> should.equal(["Alice", "Alice", "bafytest123"]) 344 + } 345 + 346 + /// Test building WHERE clause for multiple fields 347 + pub fn build_where_multi_field_test() { 348 + let decoded = cursor.DecodedCursor( 349 + field_values: ["Hello", "2025-01-15T12:00:00Z"], 350 + cid: "bafytest123", 351 + ) 352 + 353 + let sort_by = Some([#("text", "desc"), #("createdAt", "desc")]) 354 + 355 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, False) 356 + 357 + // Multi-field: progressive equality checks 358 + // (text < ?) OR (text = ? AND createdAt < ?) OR (text = ? AND createdAt = ? AND cid < ?) 359 + sql 360 + |> should.equal( 361 + "((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 < ?))", 362 + ) 363 + 364 + params 365 + |> should.equal([ 366 + "Hello", 367 + "Hello", 368 + "2025-01-15T12:00:00Z", 369 + "Hello", 370 + "2025-01-15T12:00:00Z", 371 + "bafytest123", 372 + ]) 373 + } 374 + 375 + /// Test building WHERE clause for backward pagination (before) 376 + pub fn build_where_backward_test() { 377 + let decoded = cursor.DecodedCursor( 378 + field_values: ["2025-01-15 12:00:00"], 379 + cid: "bafytest123", 380 + ) 381 + 382 + let sort_by = Some([#("indexed_at", "desc")]) 383 + 384 + // is_before = True reverses the comparison operators 385 + let #(sql, params) = cursor.build_cursor_where_clause(decoded, sort_by, True) 386 + 387 + // For before with DESC: indexed_at > cursor_value OR (indexed_at = cursor_value AND cid > cursor_cid) 388 + sql 389 + |> should.equal("((indexed_at > ?) OR (indexed_at = ? AND cid > ?))") 390 + 391 + params 392 + |> should.equal([ 393 + "2025-01-15 12:00:00", 394 + "2025-01-15 12:00:00", 395 + "bafytest123", 396 + ]) 397 + }
+41 -12
server/test/graphql_handler_integration_test.gleam
··· 149 149 record2_json, 150 150 ) 151 151 152 - // Create GraphQL query request 152 + // Create GraphQL query request with Connection structure 153 153 let query = 154 154 json.object([ 155 155 #( 156 156 "query", 157 157 json.string( 158 - "{ xyzStatusphereStatus { uri cid did collection status createdAt } }", 158 + "{ xyzStatusphereStatus { edges { node { uri cid did collection status createdAt } cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }", 159 159 ), 160 160 ), 161 161 ]) ··· 216 216 let assert Ok(_) = 217 217 database.insert_lexicon(db, "xyz.statusphere.status", lexicon) 218 218 219 - // Create GraphQL query request 219 + // Create GraphQL query request with Connection structure 220 220 let query = 221 - json.object([#("query", json.string("{ xyzStatusphereStatus { uri } }"))]) 221 + json.object([ 222 + #( 223 + "query", 224 + json.string( 225 + "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }", 226 + ), 227 + ), 228 + ]) 222 229 |> json.to_string 223 230 224 231 let request = ··· 258 265 let request = 259 266 simulate.request( 260 267 http.Get, 261 - "/graphql?query={ xyzStatusphereStatus { uri } }", 268 + "/graphql?query={ xyzStatusphereStatus { edges { node { uri } } } }", 262 269 ) 263 270 264 271 let response = graphql_handler.handle_graphql_request(request, db) ··· 424 431 425 432 // Query the first collection 426 433 let query1 = 427 - json.object([#("query", json.string("{ xyzStatusphereStatus { uri } }"))]) 434 + json.object([ 435 + #( 436 + "query", 437 + json.string( 438 + "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }", 439 + ), 440 + ), 441 + ]) 428 442 |> json.to_string 429 443 let request1 = 430 444 simulate.request(http.Post, "/graphql") ··· 461 475 462 476 // Query the second collection 463 477 let query2 = 464 - json.object([#("query", json.string("{ appBskyFeedPost { uri } }"))]) 478 + json.object([ 479 + #( 480 + "query", 481 + json.string( 482 + "{ appBskyFeedPost { edges { node { uri } } pageInfo { hasNextPage } } }", 483 + ), 484 + ), 485 + ]) 465 486 |> json.to_string 466 487 let request2 = 467 488 simulate.request(http.Post, "/graphql") ··· 514 535 Nil 515 536 }) 516 537 517 - // Query all records 538 + // Query all records with Connection structure 518 539 let query = 519 - json.object([#("query", json.string("{ xyzStatusphereStatus { uri } }"))]) 540 + json.object([ 541 + #( 542 + "query", 543 + json.string( 544 + "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }", 545 + ), 546 + ), 547 + ]) 520 548 |> json.to_string 521 549 let request = 522 550 simulate.request(http.Post, "/graphql") ··· 530 558 531 559 let assert wisp.Text(body) = response.body 532 560 533 - // Count how many URIs are in the response (should be exactly 100) 561 + // Count how many URIs are in the response 562 + // With default pagination (50 items), we should get 50 records 534 563 let uri_count = count_occurrences(body, "\"uri\"") 535 564 536 - // Should return exactly 100 records (not all 150) 565 + // Should return 50 records (the default page size) 537 566 uri_count 538 - |> should.equal(100) 567 + |> should.equal(50) 539 568 540 569 // Clean up 541 570 let assert Ok(_) = sqlight.close(db)