···11+{
22+ "lexicon": 1,
33+ "id": "com.atproto.label.defs",
44+ "defs": {
55+ "label": {
66+ "type": "object",
77+ "required": [
88+ "src",
99+ "uri",
1010+ "val",
1111+ "cts"
1212+ ],
1313+ "properties": {
1414+ "cid": {
1515+ "type": "string",
1616+ "format": "cid",
1717+ "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
1818+ },
1919+ "cts": {
2020+ "type": "string",
2121+ "format": "datetime",
2222+ "description": "Timestamp when this label was created."
2323+ },
2424+ "exp": {
2525+ "type": "string",
2626+ "format": "datetime",
2727+ "description": "Timestamp at which this label expires (no longer applies)."
2828+ },
2929+ "neg": {
3030+ "type": "boolean",
3131+ "description": "If true, this is a negation label, overwriting a previous label."
3232+ },
3333+ "sig": {
3434+ "type": "bytes",
3535+ "description": "Signature of dag-cbor encoded label."
3636+ },
3737+ "src": {
3838+ "type": "string",
3939+ "format": "did",
4040+ "description": "DID of the actor who created this label."
4141+ },
4242+ "uri": {
4343+ "type": "string",
4444+ "format": "uri",
4545+ "description": "AT URI of the record, repository (account), or other resource that this label applies to."
4646+ },
4747+ "val": {
4848+ "type": "string",
4949+ "maxLength": 128,
5050+ "description": "The short string name of the value or type of this label."
5151+ },
5252+ "ver": {
5353+ "type": "integer",
5454+ "description": "The AT Protocol version of the label object."
5555+ }
5656+ },
5757+ "description": "Metadata tag on an atproto resource (eg, repo or record)."
5858+ },
5959+ "selfLabel": {
6060+ "type": "object",
6161+ "required": [
6262+ "val"
6363+ ],
6464+ "properties": {
6565+ "val": {
6666+ "type": "string",
6767+ "maxLength": 128,
6868+ "description": "The short string name of the value or type of this label."
6969+ }
7070+ },
7171+ "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel."
7272+ },
7373+ "labelValue": {
7474+ "type": "string",
7575+ "knownValues": [
7676+ "!hide",
7777+ "!no-promote",
7878+ "!warn",
7979+ "!no-unauthenticated",
8080+ "dmca-violation",
8181+ "doxxing",
8282+ "porn",
8383+ "sexual",
8484+ "nudity",
8585+ "nsfl",
8686+ "gore"
8787+ ]
8888+ },
8989+ "selfLabels": {
9090+ "type": "object",
9191+ "required": [
9292+ "values"
9393+ ],
9494+ "properties": {
9595+ "values": {
9696+ "type": "array",
9797+ "items": {
9898+ "ref": "#selfLabel",
9999+ "type": "ref"
100100+ },
101101+ "maxLength": 10
102102+ }
103103+ },
104104+ "description": "Metadata tags on an atproto record, published by the author within the record."
105105+ },
106106+ "labelValueDefinition": {
107107+ "type": "object",
108108+ "required": [
109109+ "identifier",
110110+ "severity",
111111+ "blurs",
112112+ "locales"
113113+ ],
114114+ "properties": {
115115+ "blurs": {
116116+ "type": "string",
117117+ "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
118118+ "knownValues": [
119119+ "content",
120120+ "media",
121121+ "none"
122122+ ]
123123+ },
124124+ "locales": {
125125+ "type": "array",
126126+ "items": {
127127+ "ref": "#labelValueDefinitionStrings",
128128+ "type": "ref"
129129+ }
130130+ },
131131+ "severity": {
132132+ "type": "string",
133133+ "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
134134+ "knownValues": [
135135+ "inform",
136136+ "alert",
137137+ "none"
138138+ ]
139139+ },
140140+ "adultOnly": {
141141+ "type": "boolean",
142142+ "description": "Does the user need to have adult content enabled in order to configure this label?"
143143+ },
144144+ "identifier": {
145145+ "type": "string",
146146+ "maxLength": 100,
147147+ "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
148148+ "maxGraphemes": 100
149149+ },
150150+ "defaultSetting": {
151151+ "type": "string",
152152+ "default": "warn",
153153+ "description": "The default setting for this label.",
154154+ "knownValues": [
155155+ "ignore",
156156+ "warn",
157157+ "hide"
158158+ ]
159159+ }
160160+ },
161161+ "description": "Declares a label value and its expected interpretations and behaviors."
162162+ },
163163+ "labelValueDefinitionStrings": {
164164+ "type": "object",
165165+ "required": [
166166+ "lang",
167167+ "name",
168168+ "description"
169169+ ],
170170+ "properties": {
171171+ "lang": {
172172+ "type": "string",
173173+ "format": "language",
174174+ "description": "The code of the language these strings are written in."
175175+ },
176176+ "name": {
177177+ "type": "string",
178178+ "maxLength": 640,
179179+ "description": "A short human-readable name for the label.",
180180+ "maxGraphemes": 64
181181+ },
182182+ "description": {
183183+ "type": "string",
184184+ "maxLength": 100000,
185185+ "description": "A longer description of what the label means and why it might be applied.",
186186+ "maxGraphemes": 10000
187187+ }
188188+ },
189189+ "description": "Strings which describe the label in the UI, localized into a specific language."
190190+ }
191191+ }
192192+}
···11+---
22+version: 1.4.1
33+title: Enum without descriptions
44+file: ./test/sdl_test.gleam
55+test_name: enum_without_descriptions_test
66+---
77+enum Color {
88+ RED
99+ GREEN
1010+ BLUE
1111+}
···11+---
22+version: 1.4.1
33+title: Input object with default values
44+file: ./test/sdl_test.gleam
55+test_name: input_object_with_default_values_test
66+---
77+"""Filter options for queries"""
88+input FilterInput {
99+ """Maximum number of results"""
1010+ limit: Int
1111+ """Number of results to skip"""
1212+ offset: Int
1313+}
···11+---
22+version: 1.4.1
33+title: Simple enum type
44+file: ./test/sdl_test.gleam
55+test_name: simple_enum_test
66+---
77+"""Order status"""
88+enum Status {
99+ """Order is pending"""
1010+ PENDING
1111+ """Order is being processed"""
1212+ PROCESSING
1313+ """Order has been shipped"""
1414+ SHIPPED
1515+ """Order has been delivered"""
1616+ DELIVERED
1717+}
···11+---
22+version: 1.4.1
33+title: Type with NonNull and List modifiers
44+file: ./test/sdl_test.gleam
55+test_name: type_with_non_null_and_list_test
66+---
77+"""Complex type modifiers"""
88+input ComplexInput {
99+ """Required string"""
1010+ required: String!
1111+ """Optional list of strings"""
1212+ optionalList: [String]
1313+ """Required list of optional strings"""
1414+ requiredList: [String]!
1515+ """Optional list of required strings"""
1616+ listOfRequired: [String!]
1717+ """Required list of required strings"""
1818+ requiredListOfRequired: [String!]!
1919+}
+1
graphql/gleam.toml
···17171818[dev-dependencies]
1919gleeunit = ">= 1.0.0 and < 2.0.0"
2020+birdie = ">= 1.0.0 and < 2.0.0"
···11+---
22+version: 1.4.1
33+title: All types generated by db_schema_builder including Connection, Edge, PageInfo, SortField enum, WhereInput, etc.
44+file: ./test/sorting_test.gleam
55+test_name: db_schema_all_types_snapshot_test
66+---
77+"""Filter operators for XyzStatusphereStatus fields"""
88+input XyzStatusphereStatusFieldCondition {
99+ """Exact match (equals)"""
1010+ eq: String
1111+ """Match any value in the list"""
1212+ in: [String!]
1313+ """Case-insensitive substring match (string fields only)"""
1414+ contains: String
1515+ """Greater than"""
1616+ gt: String
1717+ """Greater than or equal to"""
1818+ gte: String
1919+ """Less than"""
2020+ lt: String
2121+ """Less than or equal to"""
2222+ lte: String
2323+}
2424+2525+"""Filter conditions for XyzStatusphereStatus with nested AND/OR support"""
2626+input XyzStatusphereStatusWhereInput {
2727+ """Filter by uri"""
2828+ uri: XyzStatusphereStatusFieldCondition
2929+ """Filter by cid"""
3030+ cid: XyzStatusphereStatusFieldCondition
3131+ """Filter by did"""
3232+ did: XyzStatusphereStatusFieldCondition
3333+ """Filter by collection"""
3434+ collection: XyzStatusphereStatusFieldCondition
3535+ """Filter by indexedAt"""
3636+ indexedAt: XyzStatusphereStatusFieldCondition
3737+ """Filter by actorHandle"""
3838+ actorHandle: XyzStatusphereStatusFieldCondition
3939+ """Filter by text"""
4040+ text: XyzStatusphereStatusFieldCondition
4141+ """Filter by createdAt"""
4242+ createdAt: XyzStatusphereStatusFieldCondition
4343+ """All conditions must match (AND logic)"""
4444+ and: [XyzStatusphereStatusWhereInput!]
4545+ """Any condition must match (OR logic)"""
4646+ or: [XyzStatusphereStatusWhereInput!]
4747+}
4848+4949+"""Sort direction for query results"""
5050+enum SortDirection {
5151+ """Ascending order"""
5252+ ASC
5353+ """Descending order"""
5454+ DESC
5555+}
5656+5757+"""Available sort fields for XyzStatusphereStatus"""
5858+enum XyzStatusphereStatusSortField {
5959+ """Sort by uri"""
6060+ uri
6161+ """Sort by cid"""
6262+ cid
6363+ """Sort by did"""
6464+ did
6565+ """Sort by collection"""
6666+ collection
6767+ """Sort by indexedAt"""
6868+ indexedAt
6969+ """Sort by text"""
7070+ text
7171+ """Sort by createdAt"""
7272+ createdAt
7373+}
7474+7575+"""Specifies a field to sort by and its direction"""
7676+input SortFieldInput {
7777+ """Field to sort by"""
7878+ field: XyzStatusphereStatusSortField!
7979+ """Sort direction (ASC or DESC)"""
8080+ direction: SortDirection!
8181+}
8282+8383+scalar Int
8484+8585+scalar Boolean
8686+8787+"""Information about pagination in a connection"""
8888+type PageInfo {
8989+ """When paginating forwards, are there more items?"""
9090+ hasNextPage: Boolean!
9191+ """When paginating backwards, are there more items?"""
9292+ hasPreviousPage: Boolean!
9393+ """Cursor corresponding to the first item in the page"""
9494+ startCursor: String
9595+ """Cursor corresponding to the last item in the page"""
9696+ endCursor: String
9797+}
9898+9999+scalar String
100100+101101+"""Record type: xyz.statusphere.status"""
102102+type XyzStatusphereStatus {
103103+ """Record URI"""
104104+ uri: String
105105+ """Record CID"""
106106+ cid: String
107107+ """DID of record author"""
108108+ did: String
109109+ """Collection name"""
110110+ collection: String
111111+ """When record was indexed"""
112112+ indexedAt: String
113113+ """Handle of the actor who created this record"""
114114+ actorHandle: String
115115+ """Field from lexicon"""
116116+ text: String
117117+ """Field from lexicon"""
118118+ createdAt: String
119119+}
120120+121121+"""An edge in a connection for XyzStatusphereStatus"""
122122+type XyzStatusphereStatusEdge {
123123+ """The item at the end of the edge"""
124124+ node: XyzStatusphereStatus!
125125+ """A cursor for use in pagination"""
126126+ cursor: String!
127127+}
128128+129129+"""A connection to a list of items for XyzStatusphereStatus"""
130130+type XyzStatusphereStatusConnection {
131131+ """A list of edges"""
132132+ edges: [XyzStatusphereStatusEdge!]!
133133+ """Information to aid in pagination"""
134134+ pageInfo: PageInfo!
135135+ """Total number of items in the connection"""
136136+ totalCount: Int
137137+}
138138+139139+"""Root query type"""
140140+type Query {
141141+ """Query xyz.statusphere.status with cursor pagination and sorting"""
142142+ xyzStatusphereStatus: XyzStatusphereStatusConnection
143143+}
144144+145145+scalar Float
146146+147147+scalar ID
···7575 )
7676}
77777878-/// Connection arguments with sortBy using a custom field enum
7878+/// Builds a WhereConditionInput type for filtering a specific field type
7979+/// Supports: eq, in, contains, gt, gte, lt, lte operators
8080+pub fn build_where_condition_input_type(
8181+ type_name: String,
8282+ field_type: schema.Type,
8383+) -> schema.Type {
8484+ let condition_type_name = type_name <> "FieldCondition"
8585+8686+ schema.input_object_type(
8787+ condition_type_name,
8888+ "Filter operators for " <> type_name <> " fields",
8989+ [
9090+ schema.input_field("eq", field_type, "Exact match (equals)", None),
9191+ schema.input_field(
9292+ "in",
9393+ schema.list_type(schema.non_null(field_type)),
9494+ "Match any value in the list",
9595+ None,
9696+ ),
9797+ schema.input_field(
9898+ "contains",
9999+ schema.string_type(),
100100+ "Case-insensitive substring match (string fields only)",
101101+ None,
102102+ ),
103103+ schema.input_field("gt", field_type, "Greater than", None),
104104+ schema.input_field("gte", field_type, "Greater than or equal to", None),
105105+ schema.input_field("lt", field_type, "Less than", None),
106106+ schema.input_field("lte", field_type, "Less than or equal to", None),
107107+ ],
108108+ )
109109+}
110110+111111+/// Builds a WhereInput type for a specific record type with all its fields
112112+/// Includes recursive AND/OR support by creating the type in two passes
113113+pub fn build_where_input_type(
114114+ type_name: String,
115115+ field_names: List(String),
116116+) -> schema.Type {
117117+ let where_input_name = type_name <> "WhereInput"
118118+119119+ // Build the string condition type (used for most fields)
120120+ let string_condition_type =
121121+ build_where_condition_input_type(type_name, schema.string_type())
122122+123123+ // Build input fields for each record field that can be filtered
124124+ let field_input_fields =
125125+ list.map(field_names, fn(field_name) {
126126+ schema.input_field(
127127+ field_name,
128128+ string_condition_type,
129129+ "Filter by " <> field_name,
130130+ None,
131131+ )
132132+ })
133133+134134+ // Create a placeholder type to reference (this will be filled in later)
135135+ // We need to create the type first, then add recursive references
136136+ let where_input_type =
137137+ schema.input_object_type(
138138+ where_input_name,
139139+ "Filter conditions for " <> type_name,
140140+ field_input_fields,
141141+ )
142142+143143+ // Add AND/OR fields that reference the type itself
144144+ // Note: This creates a recursive type structure like Slice API does
145145+ let logic_fields = [
146146+ schema.input_field(
147147+ "and",
148148+ schema.list_type(schema.non_null(where_input_type)),
149149+ "All conditions must match (AND logic)",
150150+ None,
151151+ ),
152152+ schema.input_field(
153153+ "or",
154154+ schema.list_type(schema.non_null(where_input_type)),
155155+ "Any condition must match (OR logic)",
156156+ None,
157157+ ),
158158+ ]
159159+160160+ // Rebuild the type with all fields including the recursive AND/OR
161161+ schema.input_object_type(
162162+ where_input_name,
163163+ "Filter conditions for " <> type_name <> " with nested AND/OR support",
164164+ list.append(field_input_fields, logic_fields),
165165+ )
166166+}
167167+168168+/// Connection arguments with sortBy using a custom field enum and where filtering
169169+pub fn lexicon_connection_args_with_field_enum_and_where(
170170+ field_enum: schema.Type,
171171+ where_input_type: schema.Type,
172172+) -> List(schema.Argument) {
173173+ list.flatten([
174174+ connection.forward_pagination_args(),
175175+ connection.backward_pagination_args(),
176176+ [
177177+ schema.argument(
178178+ "sortBy",
179179+ schema.list_type(schema.non_null(
180180+ sort_field_input_type_with_enum(field_enum),
181181+ )),
182182+ "Sort order for the connection",
183183+ None,
184184+ ),
185185+ schema.argument(
186186+ "where",
187187+ where_input_type,
188188+ "Filter conditions for the query",
189189+ None,
190190+ ),
191191+ ],
192192+ ])
193193+}
194194+195195+/// Connection arguments with sortBy using a custom field enum (backward compatibility)
79196pub fn lexicon_connection_args_with_field_enum(
80197 field_enum: schema.Type,
81198) -> List(schema.Argument) {
···1313import lexicon_graphql/nsid
1414import lexicon_graphql/schema_builder
1515import lexicon_graphql/type_mapper
1616+import lexicon_graphql/where_input
16171718/// Record type metadata with database resolver info
1819type RecordType {
···3233 last: option.Option(Int),
3334 before: option.Option(String),
3435 sort_by: option.Option(List(#(String, String))),
3636+ where: option.Option(where_input.WhereClause),
3537 )
3638}
37393840/// Type for a database record fetcher function with pagination support
3941/// Takes a collection NSID and pagination params, returns Connection data
4040-/// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page)
4242+/// Returns: (records_with_cursors, end_cursor, has_next_page, has_previous_page, total_count)
4143pub type RecordFetcher =
4244 fn(String, PaginationParams) ->
4345 Result(
4444- #(List(#(value.Value, String)), option.Option(String), Bool, Bool),
4646+ #(
4747+ List(#(value.Value, String)),
4848+ option.Option(String),
4949+ Bool,
5050+ Bool,
5151+ option.Option(Int),
5252+ ),
4553 String,
4654 )
4755···144152 }
145153 },
146154 ),
155155+ schema.field(
156156+ "actorHandle",
157157+ schema.string_type(),
158158+ "Handle of the actor who created this record",
159159+ fn(ctx) {
160160+ case get_field_from_context(ctx, "actorHandle") {
161161+ Ok(handle) -> Ok(value.String(handle))
162162+ Error(_) -> Ok(value.Null)
163163+ }
164164+ },
165165+ ),
147166 ]
148167149168 // Build fields from lexicon properties
···153172 let graphql_type = type_mapper.map_type(type_)
154173155174 schema.field(name, graphql_type, "Field from lexicon", fn(ctx) {
156156- // Try to extract field from the value object in context
157157- case get_nested_field_from_context(ctx, "value", name) {
158158- Ok(val) -> Ok(value.String(val))
159159- Error(_) -> Ok(value.Null)
175175+ // Special handling for blob fields
176176+ case type_ {
177177+ "blob" -> {
178178+ // Extract blob data from AT Protocol format and convert to Blob type format
179179+ case extract_blob_data(ctx, name) {
180180+ Ok(blob_value) -> Ok(blob_value)
181181+ Error(_) -> Ok(value.Null)
182182+ }
183183+ }
184184+ _ -> {
185185+ // Try to extract field from the value object in context
186186+ case get_nested_field_from_context(ctx, "value", name) {
187187+ Ok(val) -> Ok(value.String(val))
188188+ Error(_) -> Ok(value.Null)
189189+ }
190190+ }
160191 }
161192 })
162193 })
···167198168199/// Build a SortFieldEnum for a record type with all its sortable fields
169200fn build_sort_field_enum(record_type: RecordType) -> schema.Type {
170170- // Get field names from the record type
201201+ // Get field names from the record type, excluding non-sortable fields
171202 let field_names =
172203 list.map(record_type.fields, fn(field) { schema.field_name(field) })
204204+ |> list.filter(fn(name) { name != "actorHandle" })
173205174206 // Convert field names to enum values
175207 let enum_values =
···184216 )
185217}
186218219219+/// Build a WhereInput type for a record type with all its filterable fields
220220+fn build_where_input_type(record_type: RecordType) -> schema.Type {
221221+ // Get field names from the record type
222222+ let field_names =
223223+ list.map(record_type.fields, fn(field) { schema.field_name(field) })
224224+225225+ // Use the connection module to build the where input type
226226+ lexicon_connection.build_where_input_type(record_type.type_name, field_names)
227227+}
228228+187229/// Build the root Query type with fields for each record type
188230fn build_query_type(
189231 record_types: List(RecordType),
···207249 // Build custom SortFieldEnum for this record type
208250 let sort_field_enum = build_sort_field_enum(record_type)
209251210210- // Build custom connection args with type-specific sort field enum
252252+ // Build custom WhereInput type for this record type
253253+ let where_input_type = build_where_input_type(record_type)
254254+255255+ // Build custom connection args with type-specific sort field enum and where input
211256 let connection_args =
212212- lexicon_connection.lexicon_connection_args_with_field_enum(
257257+ lexicon_connection.lexicon_connection_args_with_field_enum_and_where(
213258 sort_field_enum,
259259+ where_input_type,
214260 )
215261216262 // Create query field that returns a Connection of this record type
···226272 let pagination_params = extract_pagination_params(ctx)
227273228274 // Call the fetcher function to get records with cursors from database
229229- use #(records_with_cursors, end_cursor, has_next_page, has_previous_page) <- result.try(
275275+ use #(records_with_cursors, end_cursor, has_next_page, has_previous_page, total_count) <- result.try(
230276 fetcher(collection_nsid, pagination_params),
231277 )
232278···254300 connection.Connection(
255301 edges: edges,
256302 page_info: page_info,
257257- total_count: option.None,
303303+ total_count: total_count,
258304 )
259305260306 Ok(connection.connection_to_value(conn))
···332378 _ -> option.None
333379 }
334380381381+ // Extract where argument
382382+ let where = case schema.get_argument(ctx, "where") {
383383+ option.Some(where_value) -> {
384384+ let parsed = where_input.parse_where_clause(where_value)
385385+ case where_input.is_clause_empty(parsed) {
386386+ True -> option.None
387387+ False -> option.Some(parsed)
388388+ }
389389+ }
390390+ _ -> option.None
391391+ }
392392+335393 PaginationParams(
336394 first: first,
337395 after: after,
338396 last: last,
339397 before: before,
340398 sort_by: sort_by,
399399+ where: where,
341400 )
342401}
343402···378437 _ -> Error(Nil)
379438 }
380439}
440440+441441+/// Extract blob data from AT Protocol format and convert to Blob type format
442442+/// AT Protocol blob format:
443443+/// {
444444+/// "ref": {"$link": "bafyrei..."},
445445+/// "mimeType": "image/jpeg",
446446+/// "size": 12345
447447+/// }
448448+/// Blob type expects:
449449+/// {
450450+/// "ref": "bafyrei...",
451451+/// "mime_type": "image/jpeg",
452452+/// "size": 12345,
453453+/// "did": "did:plc:..."
454454+/// }
455455+fn extract_blob_data(
456456+ ctx: schema.Context,
457457+ field_name: String,
458458+) -> Result(value.Value, Nil) {
459459+ case ctx.data {
460460+ option.Some(value.Object(fields)) -> {
461461+ // First get the DID from the top-level context
462462+ let did = case list.key_find(fields, "did") {
463463+ Ok(value.String(d)) -> d
464464+ _ -> ""
465465+ }
466466+467467+ // Then get the blob object from value.{field_name}
468468+ case list.key_find(fields, "value") {
469469+ Ok(value.Object(nested_fields)) -> {
470470+ case list.key_find(nested_fields, field_name) {
471471+ Ok(value.Object(blob_fields)) -> {
472472+ // Extract ref from {"$link": "cid..."}
473473+ let ref = case list.key_find(blob_fields, "ref") {
474474+ Ok(value.Object(ref_obj)) -> {
475475+ case list.key_find(ref_obj, "$link") {
476476+ Ok(value.String(cid)) -> cid
477477+ _ -> ""
478478+ }
479479+ }
480480+ _ -> ""
481481+ }
482482+483483+ // Extract mimeType
484484+ let mime_type = case list.key_find(blob_fields, "mimeType") {
485485+ Ok(value.String(mt)) -> mt
486486+ _ -> "image/jpeg"
487487+ }
488488+489489+ // Extract size
490490+ let size = case list.key_find(blob_fields, "size") {
491491+ Ok(value.Int(s)) -> s
492492+ _ -> 0
493493+ }
494494+495495+ // Return blob data in format expected by Blob type resolvers
496496+ Ok(value.Object([
497497+ #("ref", value.String(ref)),
498498+ #("mime_type", value.String(mime_type)),
499499+ #("size", value.Int(size)),
500500+ #("did", value.String(did)),
501501+ ]))
502502+ }
503503+ _ -> Error(Nil)
504504+ }
505505+ }
506506+ _ -> Error(Nil)
507507+ }
508508+ }
509509+ _ -> Error(Nil)
510510+ }
511511+}
···3333}
34343535pub fn map_blob_type_test() {
3636- // Blob types map to String (URL or base64)
3737- type_mapper.map_type("blob")
3838- |> should.equal(schema.string_type())
3636+ // Blob types map to Blob object type with ref, mimeType, size, and url fields
3737+ let blob_type = type_mapper.map_type("blob")
3838+3939+ schema.type_name(blob_type)
4040+ |> should.equal("Blob")
3941}
40424143pub fn map_bytes_type_test() {
-8
lexicon_graphql/test/lexicon_graphql_test.gleam
···33pub fn main() -> Nil {
44 gleeunit.main()
55}
66-77-// gleeunit test functions end in `_test`
88-pub fn hello_world_test() {
99- let name = "Joe"
1010- let greeting = "Hello, " <> name <> "!"
1111-1212- assert greeting == "Hello, Joe!"
1313-}
+134
lexicon_graphql/test/schema_builder_test.gleam
···11+/// Snapshot tests for Schema Builder
22+///
33+/// Tests GraphQL schema generation from AT Protocol lexicon definitions
44+/// Uses birdie to capture and verify the generated schemas
55+66+import birdie
77+import gleeunit/should
88+import graphql/introspection
99+import graphql/schema
1010+import graphql/sdl
1111+import lexicon_graphql/schema_builder
1212+1313+// Test building a schema from a simple lexicon
1414+pub fn simple_schema_snapshot_test() {
1515+ // Simple status lexicon with text field
1616+ let lexicon =
1717+ schema_builder.Lexicon(
1818+ id: "xyz.statusphere.status",
1919+ defs: schema_builder.Defs(
2020+ main: schema_builder.RecordDef(type_: "record", properties: [
2121+ #("text", schema_builder.Property("string", False)),
2222+ #("createdAt", schema_builder.Property("string", True)),
2323+ ]),
2424+ ),
2525+ )
2626+2727+ case schema_builder.build_schema([lexicon]) {
2828+ Ok(s) -> {
2929+ let query_type = schema.query_type(s)
3030+ let serialized = sdl.print_type(query_type)
3131+ birdie.snap(
3232+ title: "Simple status record schema",
3333+ content: serialized,
3434+ )
3535+ }
3636+ Error(_) -> should.fail()
3737+ }
3838+}
3939+4040+// Test building schema with multiple lexicons
4141+pub fn multiple_lexicons_snapshot_test() {
4242+ let status_lexicon =
4343+ schema_builder.Lexicon(
4444+ id: "xyz.statusphere.status",
4545+ defs: schema_builder.Defs(
4646+ main: schema_builder.RecordDef(type_: "record", properties: [
4747+ #("text", schema_builder.Property("string", False)),
4848+ ]),
4949+ ),
5050+ )
5151+5252+ let profile_lexicon =
5353+ schema_builder.Lexicon(
5454+ id: "xyz.statusphere.profile",
5555+ defs: schema_builder.Defs(
5656+ main: schema_builder.RecordDef(type_: "record", properties: [
5757+ #("displayName", schema_builder.Property("string", False)),
5858+ ]),
5959+ ),
6060+ )
6161+6262+ case schema_builder.build_schema([status_lexicon, profile_lexicon]) {
6363+ Ok(s) -> {
6464+ let query_type = schema.query_type(s)
6565+ let serialized = sdl.print_type(query_type)
6666+ birdie.snap(
6767+ title: "Schema with multiple record types",
6868+ content: serialized,
6969+ )
7070+ }
7171+ Error(_) -> should.fail()
7272+ }
7373+}
7474+7575+// Test that the schema has correct type names from NSID
7676+pub fn correct_type_names_snapshot_test() {
7777+ let lexicon =
7878+ schema_builder.Lexicon(
7979+ id: "app.bsky.feed.post",
8080+ defs: schema_builder.Defs(
8181+ main: schema_builder.RecordDef(type_: "record", properties: [
8282+ #("text", schema_builder.Property("string", True)),
8383+ #("replyCount", schema_builder.Property("integer", False)),
8484+ ]),
8585+ ),
8686+ )
8787+8888+ case schema_builder.build_schema([lexicon]) {
8989+ Ok(s) -> {
9090+ let query_type = schema.query_type(s)
9191+ let serialized = sdl.print_type(query_type)
9292+ birdie.snap(
9393+ title: "Schema showing PascalCase type name and camelCase field name from NSID",
9494+ content: serialized,
9595+ )
9696+ }
9797+ Error(_) -> should.fail()
9898+ }
9999+}
100100+101101+// Test empty lexicon list (keep as unit test)
102102+pub fn build_schema_with_empty_list_test() {
103103+ let result = schema_builder.build_schema([])
104104+105105+ // Should return error for empty lexicon list
106106+ should.be_error(result)
107107+}
108108+109109+// Comprehensive test showing ALL generated types
110110+pub fn simple_schema_all_types_snapshot_test() {
111111+ let lexicon =
112112+ schema_builder.Lexicon(
113113+ id: "xyz.statusphere.status",
114114+ defs: schema_builder.Defs(
115115+ main: schema_builder.RecordDef(type_: "record", properties: [
116116+ #("text", schema_builder.Property("string", False)),
117117+ #("createdAt", schema_builder.Property("string", True)),
118118+ ]),
119119+ ),
120120+ )
121121+122122+ case schema_builder.build_schema([lexicon]) {
123123+ Ok(s) -> {
124124+ // Use introspection to get ALL types in the schema
125125+ let all_types = introspection.get_all_schema_types(s)
126126+ let serialized = sdl.print_types(all_types)
127127+ birdie.snap(
128128+ title: "All types generated for simple status record",
129129+ content: serialized,
130130+ )
131131+ }
132132+ Error(_) -> should.fail()
133133+ }
134134+}
+180
lexicon_graphql/test/sorting_test.gleam
···11+/// Snapshot tests for sortBy schema generation
22+///
33+/// Tests verify that the GraphQL schema is generated correctly with:
44+/// - Custom SortFieldEnum for each record type
55+/// - SortFieldInput InputObject type
66+/// - sortBy argument on connection fields
77+/// - Pagination arguments (first, after, last, before)
88+///
99+/// Uses birdie to capture and verify the generated schemas
1010+1111+import birdie
1212+import gleam/list
1313+import gleam/option.{Some}
1414+import gleeunit/should
1515+import graphql/introspection
1616+import graphql/schema
1717+import graphql/sdl
1818+import lexicon_graphql/db_schema_builder
1919+import lexicon_graphql/schema_builder
2020+2121+// Helper to create a test schema with a mock fetcher
2222+fn create_test_schema_from_lexicons(
2323+ lexicons: List(schema_builder.Lexicon),
2424+) -> schema.Schema {
2525+ // Mock fetcher that returns empty results (we're only testing schema generation)
2626+ let fetcher = fn(_collection, _params) { Ok(#([], option.None, False, False)) }
2727+2828+ case db_schema_builder.build_schema_with_fetcher(lexicons, fetcher) {
2929+ Ok(s) -> s
3030+ Error(_) -> panic as "Failed to build test schema"
3131+ }
3232+}
3333+3434+// Test: Single lexicon creates connection field with sortBy
3535+pub fn single_lexicon_with_sorting_snapshot_test() {
3636+ let lexicon =
3737+ schema_builder.Lexicon(
3838+ "xyz.statusphere.status",
3939+ schema_builder.Defs(
4040+ schema_builder.RecordDef("record", [
4141+ #("status", schema_builder.Property("string", False)),
4242+ #("createdAt", schema_builder.Property("string", False)),
4343+ ]),
4444+ ),
4545+ )
4646+4747+ let test_schema = create_test_schema_from_lexicons([lexicon])
4848+ let query_type = schema.query_type(test_schema)
4949+5050+ let serialized = sdl.print_type(query_type)
5151+5252+ birdie.snap(
5353+ title: "Query type with connection field and sortBy argument",
5454+ content: serialized,
5555+ )
5656+}
5757+5858+// Test: Multiple lexicons create distinct fields with separate sort enums
5959+pub fn multiple_lexicons_with_distinct_sort_enums_snapshot_test() {
6060+ let lexicon1 =
6161+ schema_builder.Lexicon(
6262+ "xyz.statusphere.status",
6363+ schema_builder.Defs(
6464+ schema_builder.RecordDef("record", [
6565+ #("status", schema_builder.Property("string", False)),
6666+ #("createdAt", schema_builder.Property("string", False)),
6767+ ]),
6868+ ),
6969+ )
7070+7171+ let lexicon2 =
7272+ schema_builder.Lexicon(
7373+ "app.bsky.feed.post",
7474+ schema_builder.Defs(
7575+ schema_builder.RecordDef("record", [
7676+ #("text", schema_builder.Property("string", False)),
7777+ #("likeCount", schema_builder.Property("integer", False)),
7878+ ]),
7979+ ),
8080+ )
8181+8282+ let test_schema = create_test_schema_from_lexicons([lexicon1, lexicon2])
8383+ let query_type = schema.query_type(test_schema)
8484+8585+ let serialized = sdl.print_type(query_type)
8686+8787+ birdie.snap(
8888+ title: "Query type with multiple connection fields and distinct sort enums",
8989+ content: serialized,
9090+ )
9191+}
9292+9393+// Unit test: Verify sortBy argument is a list type
9494+pub fn sortby_argument_is_list_type_test() {
9595+ let lexicon =
9696+ schema_builder.Lexicon(
9797+ "xyz.statusphere.status",
9898+ schema_builder.Defs(
9999+ schema_builder.RecordDef("record", [
100100+ #("status", schema_builder.Property("string", False)),
101101+ ]),
102102+ ),
103103+ )
104104+105105+ let test_schema = create_test_schema_from_lexicons([lexicon])
106106+ let query_type = schema.query_type(test_schema)
107107+108108+ case schema.get_field(query_type, "xyzStatusphereStatus") {
109109+ Some(field) -> {
110110+ let args = schema.field_arguments(field)
111111+ let sortby_arg =
112112+ list.find(args, fn(arg) { schema.argument_name(arg) == "sortBy" })
113113+114114+ case sortby_arg {
115115+ Ok(arg) -> {
116116+ let arg_type = schema.argument_type(arg)
117117+ should.be_true(schema.is_list(arg_type))
118118+ }
119119+ Error(_) -> should.fail()
120120+ }
121121+ }
122122+ option.None -> should.fail()
123123+ }
124124+}
125125+126126+// Unit test: Verify connection has all pagination arguments
127127+pub fn connection_has_all_pagination_arguments_test() {
128128+ let lexicon =
129129+ schema_builder.Lexicon(
130130+ "xyz.statusphere.status",
131131+ schema_builder.Defs(
132132+ schema_builder.RecordDef("record", [
133133+ #("status", schema_builder.Property("string", False)),
134134+ ]),
135135+ ),
136136+ )
137137+138138+ let test_schema = create_test_schema_from_lexicons([lexicon])
139139+ let query_type = schema.query_type(test_schema)
140140+141141+ case schema.get_field(query_type, "xyzStatusphereStatus") {
142142+ Some(field) -> {
143143+ let args = schema.field_arguments(field)
144144+ let arg_names = list.map(args, schema.argument_name)
145145+146146+ // Verify we have all pagination arguments
147147+ should.be_true(list.contains(arg_names, "first"))
148148+ should.be_true(list.contains(arg_names, "after"))
149149+ should.be_true(list.contains(arg_names, "last"))
150150+ should.be_true(list.contains(arg_names, "before"))
151151+ should.be_true(list.contains(arg_names, "sortBy"))
152152+ }
153153+ option.None -> should.fail()
154154+ }
155155+}
156156+157157+// Comprehensive test showing ALL generated types for db_schema_builder
158158+pub fn db_schema_all_types_snapshot_test() {
159159+ let lexicon =
160160+ schema_builder.Lexicon(
161161+ "xyz.statusphere.status",
162162+ schema_builder.Defs(
163163+ schema_builder.RecordDef("record", [
164164+ #("text", schema_builder.Property("string", False)),
165165+ #("createdAt", schema_builder.Property("string", False)),
166166+ ]),
167167+ ),
168168+ )
169169+170170+ let test_schema = create_test_schema_from_lexicons([lexicon])
171171+172172+ // Use introspection to get ALL types in the schema
173173+ let all_types = introspection.get_all_schema_types(test_schema)
174174+ let serialized = sdl.print_types(all_types)
175175+176176+ birdie.snap(
177177+ title: "All types generated by db_schema_builder including Connection, Edge, PageInfo, SortField enum, WhereInput, etc.",
178178+ content: serialized,
179179+ )
180180+}
+489
lexicon_graphql/test/where_input_test.gleam
···11+/// Tests for GraphQL where input parsing
22+///
33+/// Tests the parsing of GraphQL values into WhereClause structures
44+55+import gleam/dict
66+import gleam/list
77+import gleam/option.{None, Some}
88+import gleeunit
99+import gleeunit/should
1010+import graphql/value
1111+import lexicon_graphql/where_input
1212+1313+pub fn main() {
1414+ gleeunit.main()
1515+}
1616+1717+// ===== Basic Operator Tests =====
1818+1919+pub fn parse_eq_operator_test() {
2020+ // { field: { eq: "value" } }
2121+ let condition_value = value.Object([#("eq", value.String("test_value"))])
2222+ let where_value = value.Object([#("field", condition_value)])
2323+2424+ let result = where_input.parse_where_clause(where_value)
2525+2626+ // Check that we got a condition for "field"
2727+ case dict.get(result.conditions, "field") {
2828+ Ok(condition) -> {
2929+ case condition.eq {
3030+ Some(where_input.StringValue("test_value")) -> should.be_true(True)
3131+ _ -> should.fail()
3232+ }
3333+ }
3434+ Error(_) -> should.fail()
3535+ }
3636+}
3737+3838+pub fn parse_in_operator_test() {
3939+ // { status: { in: ["active", "pending"] } }
4040+ let condition_value =
4141+ value.Object([
4242+ #(
4343+ "in",
4444+ value.List([value.String("active"), value.String("pending")]),
4545+ ),
4646+ ])
4747+ let where_value = value.Object([#("status", condition_value)])
4848+4949+ let result = where_input.parse_where_clause(where_value)
5050+5151+ case dict.get(result.conditions, "status") {
5252+ Ok(condition) -> {
5353+ case condition.in_values {
5454+ Some(values) -> {
5555+ list.length(values) |> should.equal(2)
5656+ // Check first value
5757+ case list.first(values) {
5858+ Ok(where_input.StringValue("active")) -> should.be_true(True)
5959+ _ -> should.fail()
6060+ }
6161+ }
6262+ None -> should.fail()
6363+ }
6464+ }
6565+ Error(_) -> should.fail()
6666+ }
6767+}
6868+6969+pub fn parse_contains_operator_test() {
7070+ // { text: { contains: "hello" } }
7171+ let condition_value = value.Object([#("contains", value.String("hello"))])
7272+ let where_value = value.Object([#("text", condition_value)])
7373+7474+ let result = where_input.parse_where_clause(where_value)
7575+7676+ case dict.get(result.conditions, "text") {
7777+ Ok(condition) -> {
7878+ case condition.contains {
7979+ Some("hello") -> should.be_true(True)
8080+ _ -> should.fail()
8181+ }
8282+ }
8383+ Error(_) -> should.fail()
8484+ }
8585+}
8686+8787+pub fn parse_gt_operator_test() {
8888+ // { age: { gt: 18 } }
8989+ let condition_value = value.Object([#("gt", value.Int(18))])
9090+ let where_value = value.Object([#("age", condition_value)])
9191+9292+ let result = where_input.parse_where_clause(where_value)
9393+9494+ case dict.get(result.conditions, "age") {
9595+ Ok(condition) -> {
9696+ case condition.gt {
9797+ Some(where_input.IntValue(18)) -> should.be_true(True)
9898+ _ -> should.fail()
9999+ }
100100+ }
101101+ Error(_) -> should.fail()
102102+ }
103103+}
104104+105105+pub fn parse_gte_operator_test() {
106106+ // { age: { gte: 21 } }
107107+ let condition_value = value.Object([#("gte", value.Int(21))])
108108+ let where_value = value.Object([#("age", condition_value)])
109109+110110+ let result = where_input.parse_where_clause(where_value)
111111+112112+ case dict.get(result.conditions, "age") {
113113+ Ok(condition) -> {
114114+ case condition.gte {
115115+ Some(where_input.IntValue(21)) -> should.be_true(True)
116116+ _ -> should.fail()
117117+ }
118118+ }
119119+ Error(_) -> should.fail()
120120+ }
121121+}
122122+123123+pub fn parse_lt_operator_test() {
124124+ // { price: { lt: 100 } }
125125+ let condition_value = value.Object([#("lt", value.Int(100))])
126126+ let where_value = value.Object([#("price", condition_value)])
127127+128128+ let result = where_input.parse_where_clause(where_value)
129129+130130+ case dict.get(result.conditions, "price") {
131131+ Ok(condition) -> {
132132+ case condition.lt {
133133+ Some(where_input.IntValue(100)) -> should.be_true(True)
134134+ _ -> should.fail()
135135+ }
136136+ }
137137+ Error(_) -> should.fail()
138138+ }
139139+}
140140+141141+pub fn parse_lte_operator_test() {
142142+ // { count: { lte: 50 } }
143143+ let condition_value = value.Object([#("lte", value.Int(50))])
144144+ let where_value = value.Object([#("count", condition_value)])
145145+146146+ let result = where_input.parse_where_clause(where_value)
147147+148148+ case dict.get(result.conditions, "count") {
149149+ Ok(condition) -> {
150150+ case condition.lte {
151151+ Some(where_input.IntValue(50)) -> should.be_true(True)
152152+ _ -> should.fail()
153153+ }
154154+ }
155155+ Error(_) -> should.fail()
156156+ }
157157+}
158158+159159+// ===== Multiple Operators on Same Field =====
160160+161161+pub fn parse_range_query_test() {
162162+ // { age: { gte: 18, lte: 65 } }
163163+ let condition_value =
164164+ value.Object([#("gte", value.Int(18)), #("lte", value.Int(65))])
165165+ let where_value = value.Object([#("age", condition_value)])
166166+167167+ let result = where_input.parse_where_clause(where_value)
168168+169169+ case dict.get(result.conditions, "age") {
170170+ Ok(condition) -> {
171171+ // Check both operators are present
172172+ case condition.gte, condition.lte {
173173+ Some(where_input.IntValue(18)), Some(where_input.IntValue(65)) ->
174174+ should.be_true(True)
175175+ _, _ -> should.fail()
176176+ }
177177+ }
178178+ Error(_) -> should.fail()
179179+ }
180180+}
181181+182182+// ===== Multiple Fields =====
183183+184184+pub fn parse_multiple_fields_test() {
185185+ // { name: { eq: "alice" }, age: { gt: 18 } }
186186+ let name_condition = value.Object([#("eq", value.String("alice"))])
187187+ let age_condition = value.Object([#("gt", value.Int(18))])
188188+189189+ let where_value =
190190+ value.Object([#("name", name_condition), #("age", age_condition)])
191191+192192+ let result = where_input.parse_where_clause(where_value)
193193+194194+ // Check we have 2 conditions
195195+ dict.size(result.conditions) |> should.equal(2)
196196+197197+ // Check name condition
198198+ case dict.get(result.conditions, "name") {
199199+ Ok(condition) -> {
200200+ case condition.eq {
201201+ Some(where_input.StringValue("alice")) -> should.be_true(True)
202202+ _ -> should.fail()
203203+ }
204204+ }
205205+ Error(_) -> should.fail()
206206+ }
207207+208208+ // Check age condition
209209+ case dict.get(result.conditions, "age") {
210210+ Ok(condition) -> {
211211+ case condition.gt {
212212+ Some(where_input.IntValue(18)) -> should.be_true(True)
213213+ _ -> should.fail()
214214+ }
215215+ }
216216+ Error(_) -> should.fail()
217217+ }
218218+}
219219+220220+// ===== AND Logic Tests =====
221221+222222+pub fn parse_simple_and_test() {
223223+ // { and: [{ name: { eq: "alice" } }, { age: { gt: 18 } }] }
224224+ let name_condition = value.Object([#("eq", value.String("alice"))])
225225+ let age_condition = value.Object([#("gt", value.Int(18))])
226226+227227+ let name_clause = value.Object([#("name", name_condition)])
228228+ let age_clause = value.Object([#("age", age_condition)])
229229+230230+ let where_value = value.Object([#("and", value.List([name_clause, age_clause]))])
231231+232232+ let result = where_input.parse_where_clause(where_value)
233233+234234+ // Check AND is present
235235+ case result.and {
236236+ Some(and_clauses) -> {
237237+ list.length(and_clauses) |> should.equal(2)
238238+ }
239239+ None -> should.fail()
240240+ }
241241+}
242242+243243+pub fn parse_nested_and_test() {
244244+ // { and: [{ and: [{ field: { eq: "value" } }] }] }
245245+ let inner_condition = value.Object([#("eq", value.String("value"))])
246246+ let inner_clause = value.Object([#("field", inner_condition)])
247247+ let middle_clause = value.Object([#("and", value.List([inner_clause]))])
248248+ let outer_clause = value.Object([#("and", value.List([middle_clause]))])
249249+250250+ let result = where_input.parse_where_clause(outer_clause)
251251+252252+ // Check nested structure
253253+ case result.and {
254254+ Some(outer_and) -> {
255255+ list.length(outer_and) |> should.equal(1)
256256+ // Check first clause has nested AND
257257+ case list.first(outer_and) {
258258+ Ok(middle) -> {
259259+ case middle.and {
260260+ Some(inner_and) -> {
261261+ list.length(inner_and) |> should.equal(1)
262262+ }
263263+ None -> should.fail()
264264+ }
265265+ }
266266+ Error(_) -> should.fail()
267267+ }
268268+ }
269269+ None -> should.fail()
270270+ }
271271+}
272272+273273+// ===== OR Logic Tests =====
274274+275275+pub fn parse_simple_or_test() {
276276+ // { or: [{ status: { eq: "active" } }, { status: { eq: "pending" } }] }
277277+ let active_condition = value.Object([#("eq", value.String("active"))])
278278+ let pending_condition = value.Object([#("eq", value.String("pending"))])
279279+280280+ let active_clause = value.Object([#("status", active_condition)])
281281+ let pending_clause = value.Object([#("status", pending_condition)])
282282+283283+ let where_value =
284284+ value.Object([#("or", value.List([active_clause, pending_clause]))])
285285+286286+ let result = where_input.parse_where_clause(where_value)
287287+288288+ // Check OR is present
289289+ case result.or {
290290+ Some(or_clauses) -> {
291291+ list.length(or_clauses) |> should.equal(2)
292292+ }
293293+ None -> should.fail()
294294+ }
295295+}
296296+297297+pub fn parse_nested_or_test() {
298298+ // { or: [{ or: [{ field: { eq: "value" } }] }] }
299299+ let inner_condition = value.Object([#("eq", value.String("value"))])
300300+ let inner_clause = value.Object([#("field", inner_condition)])
301301+ let middle_clause = value.Object([#("or", value.List([inner_clause]))])
302302+ let outer_clause = value.Object([#("or", value.List([middle_clause]))])
303303+304304+ let result = where_input.parse_where_clause(outer_clause)
305305+306306+ // Check nested structure
307307+ case result.or {
308308+ Some(outer_or) -> {
309309+ list.length(outer_or) |> should.equal(1)
310310+ // Check first clause has nested OR
311311+ case list.first(outer_or) {
312312+ Ok(middle) -> {
313313+ case middle.or {
314314+ Some(inner_or) -> {
315315+ list.length(inner_or) |> should.equal(1)
316316+ }
317317+ None -> should.fail()
318318+ }
319319+ }
320320+ Error(_) -> should.fail()
321321+ }
322322+ }
323323+ None -> should.fail()
324324+ }
325325+}
326326+327327+// ===== Mixed AND/OR Tests =====
328328+329329+pub fn parse_and_or_mixed_test() {
330330+ // {
331331+ // and: [
332332+ // { or: [{ a: { eq: "1" } }, { b: { eq: "2" } }] },
333333+ // { c: { eq: "3" } }
334334+ // ]
335335+ // }
336336+ let a_condition = value.Object([#("eq", value.String("1"))])
337337+ let b_condition = value.Object([#("eq", value.String("2"))])
338338+ let c_condition = value.Object([#("eq", value.String("3"))])
339339+340340+ let a_clause = value.Object([#("a", a_condition)])
341341+ let b_clause = value.Object([#("b", b_condition)])
342342+ let c_clause = value.Object([#("c", c_condition)])
343343+344344+ let or_clause = value.Object([#("or", value.List([a_clause, b_clause]))])
345345+ let where_value = value.Object([#("and", value.List([or_clause, c_clause]))])
346346+347347+ let result = where_input.parse_where_clause(where_value)
348348+349349+ // Check structure
350350+ case result.and {
351351+ Some(and_clauses) -> {
352352+ list.length(and_clauses) |> should.equal(2)
353353+354354+ // First clause should have OR
355355+ case list.first(and_clauses) {
356356+ Ok(first_clause) -> {
357357+ case first_clause.or {
358358+ Some(or_clauses) -> {
359359+ list.length(or_clauses) |> should.equal(2)
360360+ }
361361+ None -> should.fail()
362362+ }
363363+ }
364364+ Error(_) -> should.fail()
365365+ }
366366+ }
367367+ None -> should.fail()
368368+ }
369369+}
370370+371371+// ===== Edge Cases =====
372372+373373+pub fn parse_empty_object_test() {
374374+ let where_value = value.Object([])
375375+ let result = where_input.parse_where_clause(where_value)
376376+377377+ where_input.is_clause_empty(result) |> should.be_true
378378+}
379379+380380+pub fn parse_empty_and_list_test() {
381381+ let where_value = value.Object([#("and", value.List([]))])
382382+ let result = where_input.parse_where_clause(where_value)
383383+384384+ // Empty AND list should result in None or empty list
385385+ case result.and {
386386+ None -> should.be_true(True)
387387+ Some(clauses) -> list.is_empty(clauses) |> should.be_true
388388+ }
389389+}
390390+391391+pub fn parse_empty_or_list_test() {
392392+ let where_value = value.Object([#("or", value.List([]))])
393393+ let result = where_input.parse_where_clause(where_value)
394394+395395+ // Empty OR list should result in None or empty list
396396+ case result.or {
397397+ None -> should.be_true(True)
398398+ Some(clauses) -> list.is_empty(clauses) |> should.be_true
399399+ }
400400+}
401401+402402+pub fn parse_invalid_value_test() {
403403+ // Pass a non-object value
404404+ let where_value = value.String("not an object")
405405+ let result = where_input.parse_where_clause(where_value)
406406+407407+ // Should return empty clause
408408+ where_input.is_clause_empty(result) |> should.be_true
409409+}
410410+411411+pub fn parse_boolean_value_test() {
412412+ // { active: { eq: true } }
413413+ let condition_value = value.Object([#("eq", value.Boolean(True))])
414414+ let where_value = value.Object([#("active", condition_value)])
415415+416416+ let result = where_input.parse_where_clause(where_value)
417417+418418+ case dict.get(result.conditions, "active") {
419419+ Ok(condition) -> {
420420+ case condition.eq {
421421+ Some(where_input.BoolValue(True)) -> should.be_true(True)
422422+ _ -> should.fail()
423423+ }
424424+ }
425425+ Error(_) -> should.fail()
426426+ }
427427+}
428428+429429+pub fn parse_mixed_types_in_values_test() {
430430+ // { ids: { in: [1, 2, 3] } }
431431+ let condition_value =
432432+ value.Object([
433433+ #("in", value.List([value.Int(1), value.Int(2), value.Int(3)])),
434434+ ])
435435+ let where_value = value.Object([#("ids", condition_value)])
436436+437437+ let result = where_input.parse_where_clause(where_value)
438438+439439+ case dict.get(result.conditions, "ids") {
440440+ Ok(condition) -> {
441441+ case condition.in_values {
442442+ Some(values) -> {
443443+ list.length(values) |> should.equal(3)
444444+ }
445445+ None -> should.fail()
446446+ }
447447+ }
448448+ Error(_) -> should.fail()
449449+ }
450450+}
451451+452452+// ===== Complex Real-World Examples =====
453453+454454+pub fn parse_complex_user_filter_test() {
455455+ // Real-world example: find users matching complex criteria
456456+ // {
457457+ // and: [
458458+ // { or: [{ status: { eq: "active" } }, { status: { eq: "premium" } }] },
459459+ // { age: { gte: 18, lte: 65 } },
460460+ // { name: { contains: "smith" } }
461461+ // ]
462462+ // }
463463+464464+ let active_cond = value.Object([#("eq", value.String("active"))])
465465+ let premium_cond = value.Object([#("eq", value.String("premium"))])
466466+ let status_active = value.Object([#("status", active_cond)])
467467+ let status_premium = value.Object([#("status", premium_cond)])
468468+ let or_status =
469469+ value.Object([#("or", value.List([status_active, status_premium]))])
470470+471471+ let age_cond = value.Object([#("gte", value.Int(18)), #("lte", value.Int(65))])
472472+ let age_clause = value.Object([#("age", age_cond)])
473473+474474+ let name_cond = value.Object([#("contains", value.String("smith"))])
475475+ let name_clause = value.Object([#("name", name_cond)])
476476+477477+ let where_value =
478478+ value.Object([#("and", value.List([or_status, age_clause, name_clause]))])
479479+480480+ let result = where_input.parse_where_clause(where_value)
481481+482482+ // Verify structure
483483+ case result.and {
484484+ Some(and_clauses) -> {
485485+ list.length(and_clauses) |> should.equal(3)
486486+ }
487487+ None -> should.fail()
488488+ }
489489+}
+169
lexicon_graphql/test/where_schema_test.gleam
···11+/// Snapshot tests for WhereInput schema generation
22+///
33+/// Uses birdie to capture and verify the GraphQL schema types
44+/// generated for where input filtering
55+66+import birdie
77+import lexicon_graphql/connection
88+import gleeunit
99+import graphql/schema
1010+import graphql/sdl
1111+1212+pub fn main() {
1313+ gleeunit.main()
1414+}
1515+1616+// ===== Simple Record Type =====
1717+1818+pub fn simple_post_where_input_snapshot_test() {
1919+ // Simulate a simple post lexicon with basic fields
2020+ let field_names = ["uri", "cid", "text", "createdAt"]
2121+2222+ let where_input_type =
2323+ connection.build_where_input_type("AppBskyFeedPost", field_names)
2424+2525+ let serialized = sdl.print_type(where_input_type)
2626+2727+ birdie.snap(
2828+ title: "Simple Post WhereInput with basic fields",
2929+ content: serialized,
3030+ )
3131+}
3232+3333+// ===== Complex Record Type =====
3434+3535+pub fn complex_post_where_input_snapshot_test() {
3636+ // Simulate a complex post with many fields
3737+ let field_names = [
3838+ "uri", "cid", "text", "createdAt", "indexedAt", "likeCount", "replyCount",
3939+ "repostCount", "author",
4040+ ]
4141+4242+ let where_input_type =
4343+ connection.build_where_input_type("AppBskyFeedPost", field_names)
4444+4545+ let serialized = sdl.print_type(where_input_type)
4646+4747+ birdie.snap(
4848+ title: "Complex Post WhereInput with many fields",
4949+ content: serialized,
5050+ )
5151+}
5252+5353+// ===== Profile Record Type =====
5454+5555+pub fn profile_where_input_snapshot_test() {
5656+ // Simulate actor profile lexicon
5757+ let field_names = ["did", "handle", "displayName", "description", "avatar"]
5858+5959+ let where_input_type =
6060+ connection.build_where_input_type("AppBskyActorProfile", field_names)
6161+6262+ let serialized = sdl.print_type(where_input_type)
6363+6464+ birdie.snap(
6565+ title: "Profile WhereInput with actor fields",
6666+ content: serialized,
6767+ )
6868+}
6969+7070+// ===== Empty Record (Edge Case) =====
7171+7272+pub fn empty_where_input_snapshot_test() {
7373+ // Edge case: record with no filterable fields
7474+ let field_names = []
7575+7676+ let where_input_type =
7777+ connection.build_where_input_type("EmptyRecord", field_names)
7878+7979+ let serialized = sdl.print_type(where_input_type)
8080+8181+ birdie.snap(
8282+ title: "Empty WhereInput with only AND/OR fields",
8383+ content: serialized,
8484+ )
8585+}
8686+8787+// ===== Field Condition Type =====
8888+8989+pub fn field_condition_snapshot_test() {
9090+ // Test the FieldCondition type that defines operators
9191+ let condition_type =
9292+ connection.build_where_condition_input_type(
9393+ "AppBskyFeedPost",
9494+ schema.string_type(),
9595+ )
9696+9797+ let serialized = sdl.print_type(condition_type)
9898+9999+ birdie.snap(
100100+ title: "Field condition type with all operators",
101101+ content: serialized,
102102+ )
103103+}
104104+105105+// ===== Related Types Together =====
106106+107107+pub fn related_types_snapshot_test() {
108108+ // Test serializing both the WhereInput and its FieldCondition together
109109+ let field_names = ["text", "createdAt", "likes"]
110110+111111+ let where_input_type =
112112+ connection.build_where_input_type("TestRecord", field_names)
113113+114114+ let condition_type =
115115+ connection.build_where_condition_input_type(
116116+ "TestRecord",
117117+ schema.string_type(),
118118+ )
119119+120120+ let serialized =
121121+ sdl.print_types([where_input_type, condition_type])
122122+123123+ birdie.snap(
124124+ title: "WhereInput and FieldCondition types together",
125125+ content: serialized,
126126+ )
127127+}
128128+129129+// ===== Different Record Types Have Different Names =====
130130+131131+pub fn multiple_record_types_snapshot_test() {
132132+ // Verify different record types generate different WhereInput names
133133+ let post_where =
134134+ connection.build_where_input_type("AppBskyFeedPost", [
135135+ "text",
136136+ "createdAt",
137137+ ])
138138+139139+ let profile_where =
140140+ connection.build_where_input_type("AppBskyActorProfile", [
141141+ "displayName",
142142+ "description",
143143+ ])
144144+145145+ let serialized =
146146+ sdl.print_types([post_where, profile_where])
147147+148148+ birdie.snap(
149149+ title: "Multiple record types with different WhereInput names",
150150+ content: serialized,
151151+ )
152152+}
153153+154154+// ===== Verify Recursive AND/OR Fields =====
155155+156156+pub fn recursive_and_or_fields_snapshot_test() {
157157+ // This test specifically highlights the recursive AND/OR structure
158158+ let field_names = ["name", "status"]
159159+160160+ let where_input_type =
161161+ connection.build_where_input_type("RecursiveTest", field_names)
162162+163163+ let serialized = sdl.print_type(where_input_type)
164164+165165+ birdie.snap(
166166+ title: "WhereInput showing recursive AND/OR fields",
167167+ content: serialized,
168168+ )
169169+}
+2
server/.env.example
···66HOST=127.0.0.1
77# PORT: The port to listen on
88PORT=8000
99+# DOMAIN_AUTHORITY: The domain authority for this instance
1010+DOMAIN_AUTHORITY=xyz.statusphere.example.com
9111012# Database Configuration
1113DATABASE_URL=quickslice.db
···11+{
22+ "lexicon": 1,
33+ "id": "com.atproto.label.defs",
44+ "defs": {
55+ "label": {
66+ "type": "object",
77+ "required": [
88+ "src",
99+ "uri",
1010+ "val",
1111+ "cts"
1212+ ],
1313+ "properties": {
1414+ "cid": {
1515+ "type": "string",
1616+ "format": "cid",
1717+ "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
1818+ },
1919+ "cts": {
2020+ "type": "string",
2121+ "format": "datetime",
2222+ "description": "Timestamp when this label was created."
2323+ },
2424+ "exp": {
2525+ "type": "string",
2626+ "format": "datetime",
2727+ "description": "Timestamp at which this label expires (no longer applies)."
2828+ },
2929+ "neg": {
3030+ "type": "boolean",
3131+ "description": "If true, this is a negation label, overwriting a previous label."
3232+ },
3333+ "sig": {
3434+ "type": "bytes",
3535+ "description": "Signature of dag-cbor encoded label."
3636+ },
3737+ "src": {
3838+ "type": "string",
3939+ "format": "did",
4040+ "description": "DID of the actor who created this label."
4141+ },
4242+ "uri": {
4343+ "type": "string",
4444+ "format": "uri",
4545+ "description": "AT URI of the record, repository (account), or other resource that this label applies to."
4646+ },
4747+ "val": {
4848+ "type": "string",
4949+ "maxLength": 128,
5050+ "description": "The short string name of the value or type of this label."
5151+ },
5252+ "ver": {
5353+ "type": "integer",
5454+ "description": "The AT Protocol version of the label object."
5555+ }
5656+ },
5757+ "description": "Metadata tag on an atproto resource (eg, repo or record)."
5858+ },
5959+ "selfLabel": {
6060+ "type": "object",
6161+ "required": [
6262+ "val"
6363+ ],
6464+ "properties": {
6565+ "val": {
6666+ "type": "string",
6767+ "maxLength": 128,
6868+ "description": "The short string name of the value or type of this label."
6969+ }
7070+ },
7171+ "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel."
7272+ },
7373+ "labelValue": {
7474+ "type": "string",
7575+ "knownValues": [
7676+ "!hide",
7777+ "!no-promote",
7878+ "!warn",
7979+ "!no-unauthenticated",
8080+ "dmca-violation",
8181+ "doxxing",
8282+ "porn",
8383+ "sexual",
8484+ "nudity",
8585+ "nsfl",
8686+ "gore"
8787+ ]
8888+ },
8989+ "selfLabels": {
9090+ "type": "object",
9191+ "required": [
9292+ "values"
9393+ ],
9494+ "properties": {
9595+ "values": {
9696+ "type": "array",
9797+ "items": {
9898+ "ref": "#selfLabel",
9999+ "type": "ref"
100100+ },
101101+ "maxLength": 10
102102+ }
103103+ },
104104+ "description": "Metadata tags on an atproto record, published by the author within the record."
105105+ },
106106+ "labelValueDefinition": {
107107+ "type": "object",
108108+ "required": [
109109+ "identifier",
110110+ "severity",
111111+ "blurs",
112112+ "locales"
113113+ ],
114114+ "properties": {
115115+ "blurs": {
116116+ "type": "string",
117117+ "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
118118+ "knownValues": [
119119+ "content",
120120+ "media",
121121+ "none"
122122+ ]
123123+ },
124124+ "locales": {
125125+ "type": "array",
126126+ "items": {
127127+ "ref": "#labelValueDefinitionStrings",
128128+ "type": "ref"
129129+ }
130130+ },
131131+ "severity": {
132132+ "type": "string",
133133+ "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
134134+ "knownValues": [
135135+ "inform",
136136+ "alert",
137137+ "none"
138138+ ]
139139+ },
140140+ "adultOnly": {
141141+ "type": "boolean",
142142+ "description": "Does the user need to have adult content enabled in order to configure this label?"
143143+ },
144144+ "identifier": {
145145+ "type": "string",
146146+ "maxLength": 100,
147147+ "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
148148+ "maxGraphemes": 100
149149+ },
150150+ "defaultSetting": {
151151+ "type": "string",
152152+ "default": "warn",
153153+ "description": "The default setting for this label.",
154154+ "knownValues": [
155155+ "ignore",
156156+ "warn",
157157+ "hide"
158158+ ]
159159+ }
160160+ },
161161+ "description": "Declares a label value and its expected interpretations and behaviors."
162162+ },
163163+ "labelValueDefinitionStrings": {
164164+ "type": "object",
165165+ "required": [
166166+ "lang",
167167+ "name",
168168+ "description"
169169+ ],
170170+ "properties": {
171171+ "lang": {
172172+ "type": "string",
173173+ "format": "language",
174174+ "description": "The code of the language these strings are written in."
175175+ },
176176+ "name": {
177177+ "type": "string",
178178+ "maxLength": 640,
179179+ "description": "A short human-readable name for the label.",
180180+ "maxGraphemes": 64
181181+ },
182182+ "description": {
183183+ "type": "string",
184184+ "maxLength": 100000,
185185+ "description": "A longer description of what the label means and why it might be applied.",
186186+ "maxGraphemes": 10000
187187+ }
188188+ },
189189+ "description": "Strings which describe the label in the UI, localized into a specific language."
190190+ }
191191+ }
192192+}
···7272 )
7373}
74747575+/// Check if an NSID matches the configured domain authority
7676+/// NSID format is like "com.example.post" where "com.example" is the authority
7777+pub fn nsid_matches_domain_authority(nsid: String) -> Bool {
7878+ case envoy.get("DOMAIN_AUTHORITY") {
7979+ Error(_) -> False
8080+ Ok(domain_authority) -> {
8181+ // NSID format: authority.name (e.g., "com.example.post")
8282+ // We need to check if the NSID starts with the domain authority
8383+ string.starts_with(nsid, domain_authority <> ".")
8484+ }
8585+ }
8686+}
8787+7588/// Resolve a DID to get ATP data (PDS endpoint and handle)
7689pub fn resolve_did(did: String, plc_url: String) -> Result(AtprotoData, String) {
7790 let url = plc_url <> "/" <> did
+243-7
server/src/database.gleam
···77import gleam/result
88import gleam/string
99import sqlight
1010+import where_clause
10111112pub type Record {
1213 Record(
···606607 None -> [#("indexed_at", "desc")]
607608 }
608609609609- // Build the ORDER BY clause
610610- let order_by_clause = build_order_by(sort_fields)
610610+ // Build the ORDER BY clause (no joins in this function, so no prefix needed)
611611+ let order_by_clause = build_order_by(sort_fields, False)
611612612613 // Build WHERE clause parts
613614 let where_parts = ["collection = ?"]
···704705 Ok(#(final_records, next_cursor, has_next_page, has_previous_page))
705706}
706707708708+/// Paginated query for records with cursor-based pagination AND where clause filtering
709709+///
710710+/// Same as get_records_by_collection_paginated but with an additional where_clause parameter
711711+pub fn get_records_by_collection_paginated_with_where(
712712+ conn: sqlight.Connection,
713713+ collection: String,
714714+ first: Option(Int),
715715+ after: Option(String),
716716+ last: Option(Int),
717717+ before: Option(String),
718718+ sort_by: Option(List(#(String, String))),
719719+ where: Option(where_clause.WhereClause),
720720+) -> Result(#(List(Record), Option(String), Bool, Bool), sqlight.Error) {
721721+ // Validate pagination arguments
722722+ let #(limit, is_forward, cursor_opt) = case first, last {
723723+ Some(f), None -> #(f, True, after)
724724+ None, Some(l) -> #(l, False, before)
725725+ Some(f), Some(_) -> #(f, True, after)
726726+ None, None -> #(50, True, None)
727727+ }
728728+729729+ // Default sort order if not specified
730730+ let sort_fields = case sort_by {
731731+ Some(fields) -> fields
732732+ None -> [#("indexed_at", "desc")]
733733+ }
734734+735735+ // Check if we need to join with actor table
736736+ let needs_actor_join = case where {
737737+ Some(wc) -> where_clause.requires_actor_join(wc)
738738+ None -> False
739739+ }
740740+741741+ // Build the ORDER BY clause (with table prefix if doing a join)
742742+ let order_by_clause = build_order_by(sort_fields, needs_actor_join)
743743+744744+ // Build FROM clause with optional LEFT JOIN
745745+ let from_clause = case needs_actor_join {
746746+ True -> "record LEFT JOIN actor ON record.did = actor.did"
747747+ False -> "record"
748748+ }
749749+750750+ // Build WHERE clause parts - start with collection filter
751751+ let mut_where_parts = ["record.collection = ?"]
752752+ let mut_bind_values = [sqlight.text(collection)]
753753+754754+ // Add where clause conditions if provided
755755+ let #(where_parts, bind_values) = case where {
756756+ Some(wc) -> {
757757+ case where_clause.is_clause_empty(wc) {
758758+ True -> #(mut_where_parts, mut_bind_values)
759759+ False -> {
760760+ let #(where_sql, where_params) =
761761+ where_clause.build_where_sql(wc, needs_actor_join)
762762+ let new_where = list.append(mut_where_parts, [where_sql])
763763+ let new_binds = list.append(mut_bind_values, where_params)
764764+ #(new_where, new_binds)
765765+ }
766766+ }
767767+ }
768768+ None -> #(mut_where_parts, mut_bind_values)
769769+ }
770770+771771+ // Add cursor condition if present
772772+ let #(final_where_parts, final_bind_values) = case cursor_opt {
773773+ Some(cursor_str) -> {
774774+ case cursor.decode_cursor(cursor_str, sort_by) {
775775+ Ok(decoded_cursor) -> {
776776+ let #(cursor_where, cursor_params) =
777777+ cursor.build_cursor_where_clause(
778778+ decoded_cursor,
779779+ sort_by,
780780+ !is_forward,
781781+ )
782782+783783+ let new_where = list.append(where_parts, [cursor_where])
784784+ let new_binds =
785785+ list.append(
786786+ bind_values,
787787+ list.map(cursor_params, sqlight.text),
788788+ )
789789+ #(new_where, new_binds)
790790+ }
791791+ Error(_) -> #(where_parts, bind_values)
792792+ }
793793+ }
794794+ None -> #(where_parts, bind_values)
795795+ }
796796+797797+ // Fetch limit + 1 to detect if there are more pages
798798+ let fetch_limit = limit + 1
799799+800800+ // Build the SQL query
801801+ let sql =
802802+ "
803803+ SELECT record.uri, record.cid, record.did, record.collection, record.json, record.indexed_at
804804+ FROM "
805805+ <> from_clause
806806+ <> "
807807+ WHERE "
808808+ <> string.join(final_where_parts, " AND ")
809809+ <> "
810810+ ORDER BY "
811811+ <> order_by_clause
812812+ <> "
813813+ LIMIT "
814814+ <> int.to_string(fetch_limit)
815815+816816+ // Execute query
817817+ let decoder = {
818818+ use uri <- decode.field(0, decode.string)
819819+ use cid <- decode.field(1, decode.string)
820820+ use did <- decode.field(2, decode.string)
821821+ use collection <- decode.field(3, decode.string)
822822+ use json <- decode.field(4, decode.string)
823823+ use indexed_at <- decode.field(5, decode.string)
824824+ decode.success(Record(uri:, cid:, did:, collection:, json:, indexed_at:))
825825+ }
826826+827827+ use records <- result.try(sqlight.query(
828828+ sql,
829829+ on: conn,
830830+ with: final_bind_values,
831831+ expecting: decoder,
832832+ ))
833833+834834+ // Check if there are more results
835835+ let has_more = list.length(records) > limit
836836+ let final_records = case has_more {
837837+ True -> list.take(records, limit)
838838+ False -> records
839839+ }
840840+841841+ // Calculate hasNextPage and hasPreviousPage
842842+ let has_next_page = case is_forward {
843843+ True -> has_more
844844+ False -> option.is_some(cursor_opt)
845845+ }
846846+847847+ let has_previous_page = case is_forward {
848848+ True -> option.is_some(cursor_opt)
849849+ False -> has_more
850850+ }
851851+852852+ // Generate next cursor if there are more results
853853+ let next_cursor = case has_more, list.last(final_records) {
854854+ True, Ok(last_record) -> {
855855+ let record_like = record_to_record_like(last_record)
856856+ Some(cursor.generate_cursor_from_record(record_like, sort_by))
857857+ }
858858+ _, _ -> None
859859+ }
860860+861861+ Ok(#(final_records, next_cursor, has_next_page, has_previous_page))
862862+}
863863+864864+/// Gets the total count of records for a collection with optional where clause
865865+pub fn get_collection_count_with_where(
866866+ conn: sqlight.Connection,
867867+ collection: String,
868868+ where: Option(where_clause.WhereClause),
869869+) -> Result(Int, sqlight.Error) {
870870+ // Check if we need to join with actor table
871871+ let needs_actor_join = case where {
872872+ Some(wc) -> where_clause.requires_actor_join(wc)
873873+ None -> False
874874+ }
875875+876876+ // Build FROM clause with optional LEFT JOIN
877877+ let from_clause = case needs_actor_join {
878878+ True -> "record LEFT JOIN actor ON record.did = actor.did"
879879+ False -> "record"
880880+ }
881881+882882+ // Build WHERE clause parts - start with collection filter
883883+ let mut_where_parts = ["record.collection = ?"]
884884+ let mut_bind_values = [sqlight.text(collection)]
885885+886886+ // Add where clause conditions if provided
887887+ let #(where_parts, bind_values) = case where {
888888+ Some(wc) -> {
889889+ case where_clause.is_clause_empty(wc) {
890890+ True -> #(mut_where_parts, mut_bind_values)
891891+ False -> {
892892+ let #(where_sql, where_params) =
893893+ where_clause.build_where_sql(wc, needs_actor_join)
894894+ let new_where = list.append(mut_where_parts, [where_sql])
895895+ let new_binds = list.append(mut_bind_values, where_params)
896896+ #(new_where, new_binds)
897897+ }
898898+ }
899899+ }
900900+ None -> #(mut_where_parts, mut_bind_values)
901901+ }
902902+903903+ // Build the SQL query
904904+ let sql =
905905+ "
906906+ SELECT COUNT(*) as count
907907+ FROM "
908908+ <> from_clause
909909+ <> "
910910+ WHERE "
911911+ <> string.join(where_parts, " AND ")
912912+913913+ // Execute query
914914+ let decoder = {
915915+ use count <- decode.field(0, decode.int)
916916+ decode.success(count)
917917+ }
918918+919919+ case sqlight.query(sql, on: conn, with: bind_values, expecting: decoder) {
920920+ Ok([count]) -> Ok(count)
921921+ Ok(_) -> Ok(0)
922922+ Error(err) -> Error(err)
923923+ }
924924+}
925925+707926/// Converts a database Record to a cursor.RecordLike
708927pub fn record_to_record_like(record: Record) -> cursor.RecordLike {
709928 cursor.RecordLike(
···717936}
718937719938/// Builds an ORDER BY clause from sort fields
720720-fn build_order_by(sort_fields: List(#(String, String))) -> String {
939939+/// use_table_prefix: if True, prefixes table columns with "record." for joins
940940+fn build_order_by(
941941+ sort_fields: List(#(String, String)),
942942+ use_table_prefix: Bool,
943943+) -> String {
721944 let order_parts =
722945 list.map(sort_fields, fn(field) {
723946 let #(field_name, direction) = field
947947+ let table_prefix = case use_table_prefix {
948948+ True -> "record."
949949+ False -> ""
950950+ }
724951 let field_ref = case field_name {
725725- "uri" | "cid" | "did" | "collection" | "indexed_at" -> field_name
952952+ "uri" | "cid" | "did" | "collection" | "indexed_at" ->
953953+ table_prefix <> field_name
726954 // For JSON fields, check if they look like dates and handle accordingly
727955 "createdAt" | "indexedAt" -> {
728956 // Use CASE to treat invalid dates as NULL for sorting
729729- let json_field = "json_extract(json, '$." <> field_name <> "')"
957957+ let json_field =
958958+ "json_extract(" <> table_prefix <> "json, '$." <> field_name <> "')"
730959 "CASE
731960 WHEN " <> json_field <> " IS NULL THEN NULL
732961 WHEN datetime(" <> json_field <> ") IS NULL THEN NULL
733962 ELSE " <> json_field <> "
734963 END"
735964 }
736736- _ -> "json_extract(json, '$." <> field_name <> "')"
965965+ _ ->
966966+ "json_extract(" <> table_prefix <> "json, '$." <> field_name <> "')"
737967 }
738968 let dir = case string.lowercase(direction) {
739969 "asc" -> "ASC"
···744974 })
745975746976 case list.is_empty(order_parts) {
747747- True -> "indexed_at DESC NULLS LAST"
977977+ True -> {
978978+ let prefix = case use_table_prefix {
979979+ True -> "record."
980980+ False -> ""
981981+ }
982982+ prefix <> "indexed_at DESC NULLS LAST"
983983+ }
748984 False -> string.join(order_parts, ", ")
749985 }
750986}
+41-5
server/src/graphql_gleam.gleam
···1818import lexicon_graphql/db_schema_builder
1919import lexicon_graphql/lexicon_parser
2020import sqlight
2121+import where_converter
21222223/// Execute a GraphQL query against lexicons in the database
2324///
···4748 collection_nsid: String,
4849 pagination_params: db_schema_builder.PaginationParams,
4950 ) -> Result(
5050- #(List(#(value.Value, String)), option.Option(String), Bool, Bool),
5151+ #(
5252+ List(#(value.Value, String)),
5353+ option.Option(String),
5454+ Bool,
5555+ Bool,
5656+ option.Option(Int),
5757+ ),
5158 String,
5259 ) {
6060+ // Convert where clause from GraphQL types to SQL types
6161+ let where_clause = case pagination_params.where {
6262+ option.Some(graphql_where) ->
6363+ option.Some(where_converter.convert_where_clause(graphql_where))
6464+ option.None -> option.None
6565+ }
6666+6767+ // Get total count for this collection (with where filter if present)
6868+ let total_count =
6969+ database.get_collection_count_with_where(
7070+ db,
7171+ collection_nsid,
7272+ where_clause,
7373+ )
7474+ |> result.map(option.Some)
7575+ |> result.unwrap(option.None)
7676+5377 // Fetch records from database for this collection with pagination
5478 case
5555- database.get_records_by_collection_paginated(
7979+ database.get_records_by_collection_paginated_with_where(
5680 db,
5781 collection_nsid,
5882 pagination_params.first,
···6084 pagination_params.last,
6185 pagination_params.before,
6286 pagination_params.sort_by,
8787+ where_clause,
6388 )
6489 {
6565- Error(_) -> Ok(#([], option.None, False, False))
9090+ Error(_) -> Ok(#([], option.None, False, False, option.None))
6691 // Return empty result on error
6792 Ok(#(records, next_cursor, has_next_page, has_previous_page)) -> {
6893 // Convert database records to GraphQL values with cursors
6994 let graphql_records_with_cursors =
7095 list.map(records, fn(record) {
7171- let graphql_value = record_to_graphql_value(record)
9696+ let graphql_value = record_to_graphql_value(record, db)
7297 // Generate cursor for this record
7398 let record_cursor =
7499 cursor.generate_cursor_from_record(
···82107 next_cursor,
83108 has_next_page,
84109 has_previous_page,
110110+ total_count,
85111 ))
86112 }
87113 }
···112138/// Convert a database Record to a GraphQL value.Value
113139///
114140/// Creates an Object with all the record metadata plus the parsed JSON value
115115-fn record_to_graphql_value(record: database.Record) -> value.Value {
141141+fn record_to_graphql_value(
142142+ record: database.Record,
143143+ db: sqlight.Connection,
144144+) -> value.Value {
116145 // Parse the record JSON and convert to GraphQL value
117146 let value_object = case parse_json_to_value(record.json) {
118147 Ok(val) -> val
···120149 // Fallback to empty object on parse error
121150 }
122151152152+ // Look up actor handle from actor table
153153+ let actor_handle = case database.get_actor(db, record.did) {
154154+ Ok([actor, ..]) -> value.String(actor.handle)
155155+ _ -> value.Null
156156+ }
157157+123158 // Create the full record object with metadata and value
124159 value.Object([
125160 #("uri", value.String(record.uri)),
···127162 #("did", value.String(record.did)),
128163 #("collection", value.String(record.collection)),
129164 #("indexedAt", value.String(record.indexed_at)),
165165+ #("actorHandle", actor_handle),
130166 #("value", value_object),
131167 ])
132168}
+82-5
server/src/importer.gleam
···3030 " ✓ Found " <> string.inspect(list.length(file_paths)) <> " .json files",
3131 )
3232 io.println("")
3333- io.println("📝 Validating and importing lexicons...")
3333+ io.println("📝 Reading all lexicon files...")
34343535- // Import each file
3636- let results =
3535+ // Read all files first to get their content
3636+ let file_contents =
3737 file_paths
3838- |> list.map(fn(file_path) { import_single_lexicon(db, file_path) })
3838+ |> list.filter_map(fn(file_path) {
3939+ case simplifile.read(file_path) {
4040+ Ok(content) -> Ok(#(file_path, content))
4141+ Error(_) -> Error(Nil)
4242+ }
4343+ })
4444+4545+ io.println("📝 Validating all lexicons together...")
4646+4747+ // Extract all JSON strings for validation
4848+ let all_json_strings = list.map(file_contents, fn(pair) { pair.1 })
4949+5050+ // Validate all schemas together (this allows cross-references to be resolved)
5151+ let validation_result = case lexicon.validate_schemas(all_json_strings) {
5252+ Ok(_) -> {
5353+ io.println(" ✓ All lexicons validated successfully")
5454+ Ok(Nil)
5555+ }
5656+ Error(err) -> {
5757+ io.println_error(
5858+ " ✗ Validation failed: " <> format_validation_error(err),
5959+ )
6060+ Error("Validation failed")
6161+ }
6262+ }
6363+6464+ io.println("")
6565+ io.println("📝 Importing lexicons to database...")
6666+6767+ // Import each file (skip individual validation since we already validated all together)
6868+ let results = case validation_result {
6969+ Error(_) -> {
7070+ // If validation failed, don't import anything
7171+ file_paths |> list.map(fn(_) { Error("Validation failed") })
7272+ }
7373+ Ok(_) -> {
7474+ // Validation succeeded, import each lexicon
7575+ file_contents
7676+ |> list.map(fn(pair) {
7777+ let #(file_path, json_content) = pair
7878+ import_validated_lexicon(db, file_path, json_content)
7979+ })
8080+ }
8181+ }
39824083 // Calculate stats
4184 let total = list.length(results)
···157200 string.inspect(error)
158201}
159202160160-/// Imports a single lexicon file
203203+/// Imports a single lexicon file (with validation)
161204pub fn import_single_lexicon(
162205 conn: sqlight.Connection,
163206 file_path: String,
···188231 }
189232 }
190233}
234234+235235+/// Imports a lexicon that has already been validated
236236+/// Used when importing multiple lexicons that were validated together
237237+fn import_validated_lexicon(
238238+ conn: sqlight.Connection,
239239+ file_path: String,
240240+ json_content: String,
241241+) -> Result(String, String) {
242242+ let file_name = case string.split(file_path, "/") |> list.last {
243243+ Ok(name) -> name
244244+ Error(_) -> file_path
245245+ }
246246+247247+ case extract_lexicon_id(json_content) {
248248+ Ok(lexicon_id) -> {
249249+ case database.insert_lexicon(conn, lexicon_id, json_content) {
250250+ Ok(_) -> {
251251+ io.println(" ✓ " <> lexicon_id)
252252+ Ok(lexicon_id)
253253+ }
254254+ Error(_) -> {
255255+ let err_msg = file_name <> ": Database insertion failed"
256256+ io.println(" ✗ " <> err_msg)
257257+ Error(err_msg)
258258+ }
259259+ }
260260+ }
261261+ Error(err) -> {
262262+ let err_msg = file_name <> ": " <> err
263263+ io.println(" ✗ " <> err_msg)
264264+ Error(err_msg)
265265+ }
266266+ }
267267+}
+119-16
server/src/jetstream_consumer.gleam
···11+import backfill
12import database
23import envoy
34import event_handler
55+import gleam/dynamic/decode
46import gleam/erlang/process
57import gleam/int
68import gleam/io
···1820 // Get all record-type lexicons from the database
1921 case database.get_record_type_lexicons(db) {
2022 Ok(lexicons) -> {
2121- let collection_ids = list.map(lexicons, fn(lex) { lex.id })
2323+ // Separate lexicons by domain authority
2424+ let #(local_lexicons, external_lexicons) =
2525+ lexicons
2626+ |> list.partition(fn(lex) { backfill.nsid_matches_domain_authority(lex.id) })
2727+2828+ let local_collection_ids = list.map(local_lexicons, fn(lex) { lex.id })
2929+ let external_collection_ids =
3030+ list.map(external_lexicons, fn(lex) { lex.id })
22312323- case collection_ids {
3232+ // For Jetstream, only subscribe to local collections
3333+ // External collections will be filtered in the event handler based on known DIDs
3434+ let wanted_collection_ids = local_collection_ids
3535+3636+ case wanted_collection_ids {
2437 [] -> {
2538 io.println(
2626- "⚠️ No record-type lexicons found - skipping Jetstream consumer",
3939+ "⚠️ No local collections found - skipping Jetstream consumer",
2740 )
2828- io.println(" Import lexicons first to enable real-time indexing")
4141+ io.println(" Import lexicons with matching domain authority first")
2942 io.println("")
3043 Ok(Nil)
3144 }
3245 _ -> {
3346 io.println(
3447 "📋 Listening to "
3535- <> int.to_string(list.length(collection_ids))
3636- <> " collections:",
4848+ <> int.to_string(list.length(local_collection_ids))
4949+ <> " local collection(s) (all DIDs):",
3750 )
3838- list.each(collection_ids, fn(col) { io.println(" - " <> col) })
5151+ list.each(local_collection_ids, fn(col) { io.println(" - " <> col) })
5252+5353+ case external_collection_ids {
5454+ [] -> Nil
5555+ _ -> {
5656+ io.println("")
5757+ io.println(
5858+ "📋 Tracking "
5959+ <> int.to_string(list.length(external_collection_ids))
6060+ <> " external collection(s) (known DIDs only):",
6161+ )
6262+ list.each(external_collection_ids, fn(col) {
6363+ io.println(" - " <> col)
6464+ })
6565+ }
6666+ }
39674068 // Get Jetstream URL from environment variable or use default
4169 let jetstream_url = case envoy.get("JETSTREAM_URL") {
···4371 Error(_) -> "wss://jetstream2.us-east.bsky.network/subscribe"
4472 }
45734646- // Create Jetstream config with automatic retry
4747- let config =
7474+ // Create Jetstream config for local collections (no DID filter - listen to all)
7575+ let local_config =
4876 goose.JetstreamConfig(
4977 endpoint: jetstream_url,
5050- wanted_collections: collection_ids,
7878+ wanted_collections: local_collection_ids,
5179 wanted_dids: [],
5280 cursor: option.None,
5381 max_message_size_bytes: option.None,
···60886189 io.println("")
6290 io.println("Connecting to Jetstream...")
6363- io.println(" Endpoint: " <> config.endpoint)
6464- io.println(" DID filter: All DIDs (no filter)")
6565- io.println("")
9191+ io.println(" Endpoint: " <> jetstream_url)
9292+ io.println(
9393+ " Local collections: "
9494+ <> int.to_string(list.length(local_collection_ids))
9595+ <> " (all DIDs)",
9696+ )
66976767- // Start the Jetstream consumer (automatically retries on failure)
9898+ // Start the local collections consumer
6899 process.spawn_unlinked(fn() {
6969- goose.start_consumer(config, fn(event_json) {
100100+ goose.start_consumer(local_config, fn(event_json) {
70101 handle_jetstream_event(db, event_json)
71102 })
72103 })
731047474- io.println("Jetstream consumer started")
105105+ // If we have external collections, start a second consumer with DID filter
106106+ case external_collection_ids {
107107+ [] -> Nil
108108+ _ -> {
109109+ // Get all known DIDs from the database
110110+ case get_all_known_dids(db) {
111111+ Ok(known_dids) -> {
112112+ case known_dids {
113113+ [] -> {
114114+ io.println(
115115+ " External collections: "
116116+ <> int.to_string(list.length(external_collection_ids))
117117+ <> " (0 known DIDs - skipping)",
118118+ )
119119+ Nil
120120+ }
121121+ _ -> {
122122+ let external_config =
123123+ goose.JetstreamConfig(
124124+ endpoint: jetstream_url,
125125+ wanted_collections: external_collection_ids,
126126+ wanted_dids: known_dids,
127127+ cursor: option.None,
128128+ max_message_size_bytes: option.None,
129129+ compress: False,
130130+ require_hello: False,
131131+ max_backoff_seconds: 60,
132132+ log_connection_events: True,
133133+ log_retry_attempts: False,
134134+ )
135135+136136+ io.println(
137137+ " External collections: "
138138+ <> int.to_string(list.length(external_collection_ids))
139139+ <> " ("
140140+ <> int.to_string(list.length(known_dids))
141141+ <> " known DIDs)",
142142+ )
143143+144144+ // Start the external collections consumer
145145+ process.spawn_unlinked(fn() {
146146+ goose.start_consumer(external_config, fn(event_json) {
147147+ handle_jetstream_event(db, event_json)
148148+ })
149149+ })
150150+151151+ Nil
152152+ }
153153+ }
154154+ }
155155+ Error(_) -> {
156156+ io.println(
157157+ " External collections: Failed to fetch known DIDs - skipping",
158158+ )
159159+ Nil
160160+ }
161161+ }
162162+ }
163163+ }
164164+165165+ io.println("")
166166+ io.println("Jetstream consumer(s) started")
75167 io.println("")
7616877169 Ok(Nil)
···81173 Error(err) -> {
82174 Error("Failed to fetch lexicons: " <> string.inspect(err))
83175 }
176176+ }
177177+}
178178+179179+/// Get all known DIDs from the actor table
180180+fn get_all_known_dids(db: sqlight.Connection) -> Result(List(String), String) {
181181+ let sql = "SELECT did FROM actor"
182182+183183+ case sqlight.query(sql, on: db, with: [], expecting: decode.at([0], decode.string))
184184+ {
185185+ Ok(dids) -> Ok(dids)
186186+ Error(err) -> Error("Failed to fetch DIDs: " <> string.inspect(err))
84187 }
85188}
86189