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.

fix: resolve nested refs within others object types

Fixes refs like #byteSlice in object types not resolving to their
GraphQL types. The issue was that local refs weren't being expanded
before lookup in the object_types_dict.

Changes:
- object_builder: expand local refs before map_type_with_registry lookup
- builder: refactor extract_ref_object_types to build in dependency order
- Add tests for nested refs in others and cross-lexicon refs

+978 -40
+509
dev-docs/plans/2025-12-04-nested-ref-in-others.md
··· 1 + # Nested Refs in Others Object Types Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix refs within `defs.others` object types (like `facet#main.index` referencing `facet#byteSlice`) falling back to `String` instead of resolving to their proper object types. 6 + 7 + **Architecture:** Update `extract_ref_object_types` in `builder.gleam` to use `map_property_type_with_context` instead of the simple `map_type`, and ensure proper build order so dependency types exist before types that reference them. 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql library, swell GraphQL library 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + When building object types from `defs.others`, the current code in `builder.gleam:extract_ref_object_types` uses `type_mapper.map_type(property.type_)` at line 199: 16 + 17 + ```gleam 18 + let graphql_type = type_mapper.map_type(property.type_) 19 + ``` 20 + 21 + This doesn't resolve refs - it just returns `String` for `type: "ref"`. The result is that when `app.bsky.richtext.facet#main` has an `index` field referencing `#byteSlice`, it becomes `index: String` instead of `index: AppBskyRichtextFacetByteSlice`. 22 + 23 + The fix requires: 24 + 1. Building object types from `others` in dependency order (types without refs first) 25 + 2. Using `map_property_type_with_context` to properly resolve refs 26 + 27 + --- 28 + 29 + ### Task 1: Add failing test for nested refs in others 30 + 31 + **Files:** 32 + - Modify: `lexicon_graphql/test/local_ref_test.gleam` 33 + 34 + **Step 1: Write the failing test** 35 + 36 + Add this test at the end of the file: 37 + 38 + ```gleam 39 + /// Test that refs within "others" object types resolve correctly. 40 + /// This tests the pattern where facet#main has an index field that refs #byteSlice. 41 + pub fn nested_ref_in_others_resolves_test() { 42 + let lexicon = 43 + types.Lexicon( 44 + id: "app.bsky.richtext.facet", 45 + defs: types.Defs( 46 + main: Some( 47 + types.RecordDef(type_: "object", key: None, properties: [ 48 + #( 49 + "index", 50 + types.Property( 51 + type_: "ref", 52 + required: True, 53 + format: None, 54 + ref: Some("#byteSlice"), 55 + refs: None, 56 + items: None, 57 + ), 58 + ), 59 + #( 60 + "features", 61 + types.Property( 62 + type_: "array", 63 + required: True, 64 + format: None, 65 + ref: None, 66 + refs: None, 67 + items: Some(types.ArrayItems( 68 + type_: "string", 69 + ref: None, 70 + refs: None, 71 + )), 72 + ), 73 + ), 74 + ]), 75 + ), 76 + others: dict.from_list([ 77 + #( 78 + "byteSlice", 79 + types.Object( 80 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 81 + #( 82 + "byteStart", 83 + types.Property( 84 + type_: "integer", 85 + required: True, 86 + format: None, 87 + ref: None, 88 + refs: None, 89 + items: None, 90 + ), 91 + ), 92 + #( 93 + "byteEnd", 94 + types.Property( 95 + type_: "integer", 96 + required: True, 97 + format: None, 98 + ref: None, 99 + refs: None, 100 + items: None, 101 + ), 102 + ), 103 + ]), 104 + ), 105 + ), 106 + ]), 107 + ), 108 + ) 109 + 110 + let result = builder.build_schema([lexicon]) 111 + should.be_ok(result) 112 + 113 + case result { 114 + Ok(schema_val) -> { 115 + let all_types = introspection.get_all_schema_types(schema_val) 116 + let serialized = sdl.print_types(all_types) 117 + 118 + // The byteSlice type should exist 119 + string.contains(serialized, "type AppBskyRichtextFacetByteSlice") 120 + |> should.be_true 121 + 122 + // The index field should be AppBskyRichtextFacetByteSlice, NOT String 123 + string.contains(serialized, "index: AppBskyRichtextFacetByteSlice!") 124 + |> should.be_true 125 + } 126 + Error(_) -> should.fail() 127 + } 128 + } 129 + 130 + /// Test cross-lexicon ref from record to another lexicon's others type 131 + pub fn cross_lexicon_ref_to_others_test() { 132 + // Post lexicon references facet lexicon's main type 133 + let post_lexicon = 134 + types.Lexicon( 135 + id: "app.bsky.feed.post", 136 + defs: types.Defs( 137 + main: Some( 138 + types.RecordDef(type_: "record", key: None, properties: [ 139 + #( 140 + "text", 141 + types.Property( 142 + type_: "string", 143 + required: True, 144 + format: None, 145 + ref: None, 146 + refs: None, 147 + items: None, 148 + ), 149 + ), 150 + #( 151 + "facets", 152 + types.Property( 153 + type_: "array", 154 + required: False, 155 + format: None, 156 + ref: None, 157 + refs: None, 158 + items: Some(types.ArrayItems( 159 + type_: "ref", 160 + ref: Some("app.bsky.richtext.facet"), 161 + refs: None, 162 + )), 163 + ), 164 + ), 165 + ]), 166 + ), 167 + others: dict.new(), 168 + ), 169 + ) 170 + 171 + let facet_lexicon = 172 + types.Lexicon( 173 + id: "app.bsky.richtext.facet", 174 + defs: types.Defs( 175 + main: Some( 176 + types.RecordDef(type_: "object", key: None, properties: [ 177 + #( 178 + "index", 179 + types.Property( 180 + type_: "ref", 181 + required: True, 182 + format: None, 183 + ref: Some("#byteSlice"), 184 + refs: None, 185 + items: None, 186 + ), 187 + ), 188 + ]), 189 + ), 190 + others: dict.from_list([ 191 + #( 192 + "byteSlice", 193 + types.Object( 194 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 195 + #( 196 + "byteStart", 197 + types.Property( 198 + type_: "integer", 199 + required: True, 200 + format: None, 201 + ref: None, 202 + refs: None, 203 + items: None, 204 + ), 205 + ), 206 + #( 207 + "byteEnd", 208 + types.Property( 209 + type_: "integer", 210 + required: True, 211 + format: None, 212 + ref: None, 213 + refs: None, 214 + items: None, 215 + ), 216 + ), 217 + ]), 218 + ), 219 + ), 220 + ]), 221 + ), 222 + ) 223 + 224 + let result = builder.build_schema([post_lexicon, facet_lexicon]) 225 + should.be_ok(result) 226 + 227 + case result { 228 + Ok(schema_val) -> { 229 + let all_types = introspection.get_all_schema_types(schema_val) 230 + let serialized = sdl.print_types(all_types) 231 + 232 + // The facet type should exist 233 + string.contains(serialized, "type AppBskyRichtextFacet") 234 + |> should.be_true 235 + 236 + // The byteSlice type should exist 237 + string.contains(serialized, "type AppBskyRichtextFacetByteSlice") 238 + |> should.be_true 239 + 240 + // facets field should be array of AppBskyRichtextFacet 241 + string.contains(serialized, "facets: [AppBskyRichtextFacet!]") 242 + |> should.be_true 243 + 244 + // index field in facet should be byteSlice type, NOT String 245 + string.contains(serialized, "index: AppBskyRichtextFacetByteSlice!") 246 + |> should.be_true 247 + } 248 + Error(_) -> should.fail() 249 + } 250 + } 251 + ``` 252 + 253 + **Step 2: Run test to verify it fails** 254 + 255 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 256 + 257 + Expected: FAIL - `index: String!` instead of `index: AppBskyRichtextFacetByteSlice!` 258 + 259 + **Step 3: Commit failing test** 260 + 261 + ```bash 262 + git add lexicon_graphql/test/local_ref_test.gleam 263 + git commit -m "test: add failing tests for nested refs in others object types" 264 + ``` 265 + 266 + --- 267 + 268 + ### Task 2: Refactor extract_ref_object_types to build in dependency order 269 + 270 + **Files:** 271 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam:183-229` 272 + 273 + **Step 1: Replace extract_ref_object_types function** 274 + 275 + Replace the entire `extract_ref_object_types` function with this new implementation: 276 + 277 + ```gleam 278 + /// Extract object types from lexicon "others" defs (e.g., #artist, #aspectRatio) 279 + /// Returns a dict keyed by full ref like "fm.teal.alpha.feed.track#artist" 280 + /// 281 + /// Builds types in two passes: 282 + /// 1. First pass: build types that have no ref dependencies (leaf types) 283 + /// 2. Second pass: build types that reference other types (can now resolve refs) 284 + fn extract_ref_object_types( 285 + lexicons: List(Lexicon), 286 + ) -> dict.Dict(String, schema.Type) { 287 + // Collect all object defs with their metadata 288 + let all_defs = 289 + list.flat_map(lexicons, fn(lexicon) { 290 + let types.Lexicon(id, types.Defs(_, others)) = lexicon 291 + dict.to_list(others) 292 + |> list.filter_map(fn(entry) { 293 + let #(def_name, def) = entry 294 + case def { 295 + types.Object(obj_def) -> { 296 + let full_ref = id <> "#" <> def_name 297 + Ok(#(full_ref, id, obj_def)) 298 + } 299 + types.Record(_) -> Error(Nil) 300 + } 301 + }) 302 + }) 303 + 304 + // Partition into types with refs and types without refs 305 + let #(with_refs, without_refs) = 306 + list.partition(all_defs, fn(entry) { 307 + let #(_, _, obj_def) = entry 308 + has_ref_properties(obj_def) 309 + }) 310 + 311 + // First pass: build leaf types (no ref dependencies) 312 + let leaf_types = 313 + list.fold(without_refs, dict.new(), fn(acc, entry) { 314 + let #(full_ref, lexicon_id, obj_def) = entry 315 + let object_type = build_others_object_type(full_ref, lexicon_id, obj_def, acc) 316 + dict.insert(acc, full_ref, object_type) 317 + }) 318 + 319 + // Second pass: build types that have refs (can now resolve to leaf types) 320 + list.fold(with_refs, leaf_types, fn(acc, entry) { 321 + let #(full_ref, lexicon_id, obj_def) = entry 322 + let object_type = build_others_object_type(full_ref, lexicon_id, obj_def, acc) 323 + dict.insert(acc, full_ref, object_type) 324 + }) 325 + } 326 + 327 + /// Check if an ObjectDef has any ref properties 328 + fn has_ref_properties(obj_def: types.ObjectDef) -> Bool { 329 + list.any(obj_def.properties, fn(prop) { 330 + let #(_, property) = prop 331 + property.type_ == "ref" || property.type_ == "union" || case property.items { 332 + option.Some(items) -> items.type_ == "ref" || option.is_some(items.refs) 333 + option.None -> False 334 + } 335 + }) 336 + } 337 + 338 + /// Build a single object type from an ObjectDef in "others" 339 + fn build_others_object_type( 340 + full_ref: String, 341 + lexicon_id: String, 342 + obj_def: types.ObjectDef, 343 + existing_types: dict.Dict(String, schema.Type), 344 + ) -> schema.Type { 345 + let type_name = nsid.to_type_name(string.replace(full_ref, "#", ".")) 346 + 347 + let lexicon_fields = 348 + list.map(obj_def.properties, fn(prop) { 349 + let #(name, property) = prop 350 + let graphql_type = 351 + type_mapper.map_property_type_with_context( 352 + property, 353 + existing_types, 354 + type_name, 355 + name, 356 + lexicon_id, 357 + ) 358 + 359 + // Apply required/non-null wrapper 360 + let field_type = case list.contains(obj_def.required_fields, name) { 361 + True -> schema.non_null(graphql_type) 362 + False -> graphql_type 363 + } 364 + 365 + schema.field( 366 + name, 367 + field_type, 368 + "Field from object def", 369 + fn(_ctx) { Ok(value.Null) }, 370 + ) 371 + }) 372 + 373 + // GraphQL requires at least one field - add placeholder for empty objects 374 + let fields = case lexicon_fields { 375 + [] -> [ 376 + schema.field( 377 + "_", 378 + schema.boolean_type(), 379 + "Placeholder field for empty object type", 380 + fn(_ctx) { Ok(value.Boolean(True)) }, 381 + ), 382 + ] 383 + _ -> lexicon_fields 384 + } 385 + 386 + schema.object_type(type_name, "Object type: " <> full_ref, fields) 387 + } 388 + ``` 389 + 390 + **Step 2: Add string import if not present** 391 + 392 + Check imports at top of file and ensure `import gleam/string` is present. 393 + 394 + **Step 3: Run build** 395 + 396 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 397 + 398 + Expected: Build succeeds 399 + 400 + **Step 4: Commit** 401 + 402 + ```bash 403 + git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam 404 + git commit -m "feat(builder): resolve refs within others object types 405 + 406 + Refactor extract_ref_object_types to: 407 + 1. Build leaf types (no ref dependencies) first 408 + 2. Build types with refs second (can now resolve) 409 + 3. Use map_property_type_with_context instead of map_type 410 + 411 + This fixes nested refs like facet#main.index -> facet#byteSlice" 412 + ``` 413 + 414 + --- 415 + 416 + ### Task 3: Run tests and verify fix 417 + 418 + **Files:** 419 + - None (verification only) 420 + 421 + **Step 1: Run all tests** 422 + 423 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 424 + 425 + Expected: All tests pass including new `nested_ref_in_others_resolves_test` and `cross_lexicon_ref_to_others_test` 426 + 427 + **Step 2: If tests fail, add debug output** 428 + 429 + If tests fail, temporarily add to the test: 430 + 431 + ```gleam 432 + import gleam/io 433 + // ... in test ... 434 + io.println(serialized) 435 + ``` 436 + 437 + **Step 3: Commit passing tests** 438 + 439 + ```bash 440 + git add -A 441 + git commit -m "test: verify nested refs in others resolve correctly" 442 + ``` 443 + 444 + --- 445 + 446 + ### Task 4: Verify with live GraphQL query 447 + 448 + **Files:** 449 + - None (verification only) 450 + 451 + **Step 1: Rebuild the server** 452 + 453 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 454 + 455 + **Step 2: Query the schema via MCP** 456 + 457 + Use `mcp__quickslice__execute_query` to verify: 458 + 459 + ```graphql 460 + query { 461 + appBskyFeedPost { 462 + edges { 463 + node { 464 + facets { 465 + index { 466 + byteStart 467 + byteEnd 468 + } 469 + } 470 + } 471 + } 472 + } 473 + } 474 + ``` 475 + 476 + Expected: Query succeeds with `index` returning an object with `byteStart` and `byteEnd` fields (integers), not the raw JSON object. 477 + 478 + **Step 3: Introspect the type** 479 + 480 + ```graphql 481 + { 482 + __type(name: "AppBskyRichtextFacet") { 483 + fields { 484 + name 485 + type { 486 + name 487 + kind 488 + ofType { 489 + name 490 + kind 491 + } 492 + } 493 + } 494 + } 495 + } 496 + ``` 497 + 498 + Expected: `index` field shows type `AppBskyRichtextFacetByteSlice` (OBJECT), not `String` (SCALAR) 499 + 500 + --- 501 + 502 + ## Summary 503 + 504 + | Task | Description | Files | 505 + |------|-------------|-------| 506 + | 1 | Add failing tests for nested refs | `test/local_ref_test.gleam` | 507 + | 2 | Refactor extract_ref_object_types | `builder.gleam` | 508 + | 3 | Verify tests pass | - | 509 + | 4 | Verify with live query | - |
+5 -2
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
··· 91 91 name, 92 92 ) 93 93 } 94 - _ -> 94 + _ -> { 95 + // Expand local refs (e.g., "#byteSlice" -> "app.bsky.richtext.facet#byteSlice") 96 + let expanded_ref = option.map(ref, fn(r) { expand_ref(r, lexicon_id) }) 95 97 type_mapper.map_type_with_registry( 96 98 type_, 97 99 format, 98 - ref, 100 + expanded_ref, 99 101 object_types_dict, 100 102 ) 103 + } 101 104 } 102 105 103 106 // Make required fields non-null
+101 -38
lexicon_graphql/src/lexicon_graphql/schema/builder.gleam
··· 5 5 import gleam/dict 6 6 import gleam/list 7 7 import gleam/option 8 + import gleam/string 8 9 import lexicon_graphql/internal/graphql/type_mapper 9 10 import lexicon_graphql/internal/lexicon/nsid 10 11 import lexicon_graphql/mutation/builder as mutation_builder ··· 180 181 181 182 /// Extract object types from lexicon "others" defs (e.g., #artist, #aspectRatio) 182 183 /// Returns a dict keyed by full ref like "fm.teal.alpha.feed.track#artist" 184 + /// 185 + /// Builds types in two passes: 186 + /// 1. First pass: build types that have no ref dependencies (leaf types) 187 + /// 2. Second pass: build types that reference other types (can now resolve refs) 183 188 fn extract_ref_object_types( 184 189 lexicons: List(Lexicon), 185 190 ) -> dict.Dict(String, schema.Type) { 186 - list.fold(lexicons, dict.new(), fn(acc, lexicon) { 187 - let types.Lexicon(id, types.Defs(_, others)) = lexicon 188 - dict.fold(others, acc, fn(inner_acc, def_name, def) { 189 - case def { 190 - types.Object(types.ObjectDef(_, _, properties)) -> { 191 - // Build full ref: "fm.teal.alpha.feed.track#artist" 192 - let full_ref = id <> "#" <> def_name 193 - let type_name = nsid.to_type_name(id <> "." <> def_name) 191 + // Collect all object defs with their metadata 192 + let all_defs = 193 + list.flat_map(lexicons, fn(lexicon) { 194 + let types.Lexicon(id, types.Defs(_, others)) = lexicon 195 + dict.to_list(others) 196 + |> list.filter_map(fn(entry) { 197 + let #(def_name, def) = entry 198 + case def { 199 + types.Object(obj_def) -> { 200 + let full_ref = id <> "#" <> def_name 201 + Ok(#(full_ref, id, obj_def)) 202 + } 203 + types.Record(_) -> Error(Nil) 204 + } 205 + }) 206 + }) 194 207 195 - // Build fields for the object type 196 - let lexicon_fields = 197 - list.map(properties, fn(prop) { 198 - let #(name, property) = prop 199 - let graphql_type = type_mapper.map_type(property.type_) 200 - schema.field( 201 - name, 202 - graphql_type, 203 - "Field from object def", 204 - fn(_ctx) { Ok(value.Null) }, 205 - ) 206 - }) 208 + // Partition into types with refs and types without refs 209 + let #(with_refs, without_refs) = 210 + list.partition(all_defs, fn(entry) { 211 + let #(_, _, obj_def) = entry 212 + has_ref_properties(obj_def) 213 + }) 207 214 208 - // GraphQL requires at least one field - add placeholder for empty objects 209 - let fields = case lexicon_fields { 210 - [] -> [ 211 - schema.field( 212 - "_", 213 - schema.boolean_type(), 214 - "Placeholder field for empty object type", 215 - fn(_ctx) { Ok(value.Boolean(True)) }, 216 - ), 217 - ] 218 - _ -> lexicon_fields 219 - } 215 + // First pass: build leaf types (no ref dependencies) 216 + let leaf_types = 217 + list.fold(without_refs, dict.new(), fn(acc, entry) { 218 + let #(full_ref, lexicon_id, obj_def) = entry 219 + let object_type = 220 + build_others_object_type(full_ref, lexicon_id, obj_def, acc) 221 + dict.insert(acc, full_ref, object_type) 222 + }) 220 223 221 - let object_type = 222 - schema.object_type(type_name, "Object type: " <> full_ref, fields) 223 - dict.insert(inner_acc, full_ref, object_type) 224 - } 225 - types.Record(_) -> inner_acc 224 + // Second pass: build types that have refs (can now resolve to leaf types) 225 + list.fold(with_refs, leaf_types, fn(acc, entry) { 226 + let #(full_ref, lexicon_id, obj_def) = entry 227 + let object_type = 228 + build_others_object_type(full_ref, lexicon_id, obj_def, acc) 229 + dict.insert(acc, full_ref, object_type) 230 + }) 231 + } 232 + 233 + /// Check if an ObjectDef has any ref properties 234 + fn has_ref_properties(obj_def: types.ObjectDef) -> Bool { 235 + list.any(obj_def.properties, fn(prop) { 236 + let #(_, property) = prop 237 + property.type_ == "ref" 238 + || property.type_ == "union" 239 + || case property.items { 240 + option.Some(items) -> items.type_ == "ref" || option.is_some(items.refs) 241 + option.None -> False 242 + } 243 + }) 244 + } 245 + 246 + /// Build a single object type from an ObjectDef in "others" 247 + fn build_others_object_type( 248 + full_ref: String, 249 + lexicon_id: String, 250 + obj_def: types.ObjectDef, 251 + existing_types: dict.Dict(String, schema.Type), 252 + ) -> schema.Type { 253 + let type_name = nsid.to_type_name(string.replace(full_ref, "#", ".")) 254 + 255 + let lexicon_fields = 256 + list.map(obj_def.properties, fn(prop) { 257 + let #(name, property) = prop 258 + let graphql_type = 259 + type_mapper.map_property_type_with_context( 260 + property, 261 + existing_types, 262 + type_name, 263 + name, 264 + lexicon_id, 265 + ) 266 + 267 + // Apply required/non-null wrapper 268 + let field_type = case list.contains(obj_def.required_fields, name) { 269 + True -> schema.non_null(graphql_type) 270 + False -> graphql_type 226 271 } 272 + 273 + schema.field(name, field_type, "Field from object def", fn(_ctx) { 274 + Ok(value.Null) 275 + }) 227 276 }) 228 - }) 277 + 278 + // GraphQL requires at least one field - add placeholder for empty objects 279 + let fields = case lexicon_fields { 280 + [] -> [ 281 + schema.field( 282 + "_", 283 + schema.boolean_type(), 284 + "Placeholder field for empty object type", 285 + fn(_ctx) { Ok(value.Boolean(True)) }, 286 + ), 287 + ] 288 + _ -> lexicon_fields 289 + } 290 + 291 + schema.object_type(type_name, "Object type: " <> full_ref, fields) 229 292 } 230 293 231 294 /// Build the root Query type with fields for each record type
+363
lexicon_graphql/test/local_ref_test.gleam
··· 5 5 import gleam/option.{None, Some} 6 6 import gleam/string 7 7 import gleeunit/should 8 + import lexicon_graphql/internal/lexicon/parser 8 9 import lexicon_graphql/schema/builder 9 10 import lexicon_graphql/types 10 11 import swell/introspection ··· 188 189 Error(_) -> should.fail() 189 190 } 190 191 } 192 + 193 + /// Test that refs within "others" object types resolve correctly. 194 + /// This tests the pattern where an others object type has a field that refs another others type. 195 + /// We need a record type to make the types reachable from Query. 196 + pub fn nested_ref_in_others_resolves_test() { 197 + // A record type that references an "others" object type 198 + let record_lexicon = 199 + types.Lexicon( 200 + id: "app.bsky.feed.post", 201 + defs: types.Defs( 202 + main: Some( 203 + types.RecordDef(type_: "record", key: None, properties: [ 204 + #( 205 + "text", 206 + types.Property( 207 + type_: "string", 208 + required: True, 209 + format: None, 210 + ref: None, 211 + refs: None, 212 + items: None, 213 + ), 214 + ), 215 + #( 216 + "entity", 217 + types.Property( 218 + type_: "ref", 219 + required: False, 220 + format: None, 221 + ref: Some("#entityRef"), 222 + refs: None, 223 + items: None, 224 + ), 225 + ), 226 + ]), 227 + ), 228 + others: dict.from_list([ 229 + #( 230 + "entityRef", 231 + types.Object( 232 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 233 + #( 234 + "type", 235 + types.Property( 236 + type_: "string", 237 + required: True, 238 + format: None, 239 + ref: None, 240 + refs: None, 241 + items: None, 242 + ), 243 + ), 244 + #( 245 + "mention", 246 + types.Property( 247 + type_: "ref", 248 + required: False, 249 + format: None, 250 + ref: Some("#mentionRef"), 251 + refs: None, 252 + items: None, 253 + ), 254 + ), 255 + ]), 256 + ), 257 + ), 258 + #( 259 + "mentionRef", 260 + types.Object( 261 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 262 + #( 263 + "did", 264 + types.Property( 265 + type_: "string", 266 + required: True, 267 + format: None, 268 + ref: None, 269 + refs: None, 270 + items: None, 271 + ), 272 + ), 273 + ]), 274 + ), 275 + ), 276 + ]), 277 + ), 278 + ) 279 + 280 + let result = builder.build_schema([record_lexicon]) 281 + should.be_ok(result) 282 + 283 + case result { 284 + Ok(schema_val) -> { 285 + let all_types = introspection.get_all_schema_types(schema_val) 286 + let serialized = sdl.print_types(all_types) 287 + 288 + // The entityRef type should exist 289 + string.contains(serialized, "type AppBskyFeedPostEntityRef") 290 + |> should.be_true 291 + 292 + // The mentionRef type should exist 293 + string.contains(serialized, "type AppBskyFeedPostMentionRef") 294 + |> should.be_true 295 + 296 + // The mention field in entityRef should be AppBskyFeedPostMentionRef, NOT String 297 + string.contains(serialized, "mention: AppBskyFeedPostMentionRef") 298 + |> should.be_true 299 + } 300 + Error(_) -> should.fail() 301 + } 302 + } 303 + 304 + /// Test cross-lexicon ref from record to another lexicon's others type 305 + pub fn cross_lexicon_ref_to_others_test() { 306 + // Post lexicon references facet lexicon's main type 307 + let post_lexicon = 308 + types.Lexicon( 309 + id: "app.bsky.feed.post", 310 + defs: types.Defs( 311 + main: Some( 312 + types.RecordDef(type_: "record", key: None, properties: [ 313 + #( 314 + "text", 315 + types.Property( 316 + type_: "string", 317 + required: True, 318 + format: None, 319 + ref: None, 320 + refs: None, 321 + items: None, 322 + ), 323 + ), 324 + #( 325 + "facets", 326 + types.Property( 327 + type_: "array", 328 + required: False, 329 + format: None, 330 + ref: None, 331 + refs: None, 332 + items: Some(types.ArrayItems( 333 + type_: "ref", 334 + ref: Some("app.bsky.richtext.facet"), 335 + refs: None, 336 + )), 337 + ), 338 + ), 339 + ]), 340 + ), 341 + others: dict.new(), 342 + ), 343 + ) 344 + 345 + let facet_lexicon = 346 + types.Lexicon( 347 + id: "app.bsky.richtext.facet", 348 + defs: types.Defs( 349 + main: Some( 350 + types.RecordDef(type_: "object", key: None, properties: [ 351 + #( 352 + "index", 353 + types.Property( 354 + type_: "ref", 355 + required: True, 356 + format: None, 357 + ref: Some("#byteSlice"), 358 + refs: None, 359 + items: None, 360 + ), 361 + ), 362 + ]), 363 + ), 364 + others: dict.from_list([ 365 + #( 366 + "byteSlice", 367 + types.Object( 368 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 369 + #( 370 + "byteStart", 371 + types.Property( 372 + type_: "integer", 373 + required: True, 374 + format: None, 375 + ref: None, 376 + refs: None, 377 + items: None, 378 + ), 379 + ), 380 + #( 381 + "byteEnd", 382 + types.Property( 383 + type_: "integer", 384 + required: True, 385 + format: None, 386 + ref: None, 387 + refs: None, 388 + items: None, 389 + ), 390 + ), 391 + ]), 392 + ), 393 + ), 394 + ]), 395 + ), 396 + ) 397 + 398 + let result = builder.build_schema([post_lexicon, facet_lexicon]) 399 + should.be_ok(result) 400 + 401 + case result { 402 + Ok(schema_val) -> { 403 + let all_types = introspection.get_all_schema_types(schema_val) 404 + let serialized = sdl.print_types(all_types) 405 + 406 + // The facet type should exist 407 + string.contains(serialized, "type AppBskyRichtextFacet") 408 + |> should.be_true 409 + 410 + // The byteSlice type should exist 411 + string.contains(serialized, "type AppBskyRichtextFacetByteSlice") 412 + |> should.be_true 413 + 414 + // facets field should be array of AppBskyRichtextFacet 415 + string.contains(serialized, "facets: [AppBskyRichtextFacet!]") 416 + |> should.be_true 417 + 418 + // index field in facet should be byteSlice type, NOT String 419 + // Note: we check without ! since required is based on ObjectDef.required_fields, not Property.required 420 + string.contains(serialized, "index: AppBskyRichtextFacetByteSlice") 421 + |> should.be_true 422 + } 423 + Error(_) -> should.fail() 424 + } 425 + } 426 + 427 + /// Test with the REAL facet lexicon JSON to debug the actual issue 428 + pub fn real_facet_lexicon_json_test() { 429 + let facet_json = 430 + "{ 431 + \"lexicon\": 1, 432 + \"id\": \"app.bsky.richtext.facet\", 433 + \"defs\": { 434 + \"tag\": { 435 + \"type\": \"object\", 436 + \"required\": [\"tag\"], 437 + \"properties\": { 438 + \"tag\": {\"type\": \"string\"} 439 + } 440 + }, 441 + \"link\": { 442 + \"type\": \"object\", 443 + \"required\": [\"uri\"], 444 + \"properties\": { 445 + \"uri\": {\"type\": \"string\", \"format\": \"uri\"} 446 + } 447 + }, 448 + \"main\": { 449 + \"type\": \"object\", 450 + \"required\": [\"index\", \"features\"], 451 + \"properties\": { 452 + \"index\": { 453 + \"ref\": \"#byteSlice\", 454 + \"type\": \"ref\" 455 + }, 456 + \"features\": { 457 + \"type\": \"array\", 458 + \"items\": { 459 + \"refs\": [\"#mention\", \"#link\", \"#tag\"], 460 + \"type\": \"union\" 461 + } 462 + } 463 + } 464 + }, 465 + \"mention\": { 466 + \"type\": \"object\", 467 + \"required\": [\"did\"], 468 + \"properties\": { 469 + \"did\": {\"type\": \"string\", \"format\": \"did\"} 470 + } 471 + }, 472 + \"byteSlice\": { 473 + \"type\": \"object\", 474 + \"required\": [\"byteStart\", \"byteEnd\"], 475 + \"properties\": { 476 + \"byteEnd\": {\"type\": \"integer\"}, 477 + \"byteStart\": {\"type\": \"integer\"} 478 + } 479 + } 480 + } 481 + }" 482 + 483 + // Parse the JSON 484 + let parsed = parser.parse_lexicon(facet_json) 485 + should.be_ok(parsed) 486 + 487 + case parsed { 488 + Ok(lexicon) -> { 489 + // Build the schema with a record that references the facet 490 + let post_lexicon = 491 + types.Lexicon( 492 + id: "app.bsky.feed.post", 493 + defs: types.Defs( 494 + main: Some( 495 + types.RecordDef(type_: "record", key: None, properties: [ 496 + #( 497 + "text", 498 + types.Property( 499 + type_: "string", 500 + required: True, 501 + format: None, 502 + ref: None, 503 + refs: None, 504 + items: None, 505 + ), 506 + ), 507 + #( 508 + "facets", 509 + types.Property( 510 + type_: "array", 511 + required: False, 512 + format: None, 513 + ref: None, 514 + refs: None, 515 + items: Some(types.ArrayItems( 516 + type_: "ref", 517 + ref: Some("app.bsky.richtext.facet"), 518 + refs: None, 519 + )), 520 + ), 521 + ), 522 + ]), 523 + ), 524 + others: dict.new(), 525 + ), 526 + ) 527 + 528 + let result = builder.build_schema([post_lexicon, lexicon]) 529 + should.be_ok(result) 530 + 531 + case result { 532 + Ok(schema_val) -> { 533 + let all_types = introspection.get_all_schema_types(schema_val) 534 + let serialized = sdl.print_types(all_types) 535 + 536 + // The facet type should exist 537 + string.contains(serialized, "type AppBskyRichtextFacet") 538 + |> should.be_true 539 + 540 + // The byteSlice type should exist 541 + string.contains(serialized, "type AppBskyRichtextFacetByteSlice") 542 + |> should.be_true 543 + 544 + // index field in facet should be byteSlice type, NOT String 545 + string.contains(serialized, "index: AppBskyRichtextFacetByteSlice") 546 + |> should.be_true 547 + } 548 + Error(_) -> should.fail() 549 + } 550 + } 551 + Error(_) -> should.fail() 552 + } 553 + }