Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 791 lines 22 kB view raw view rendered
1# Nested StrongRef Resolution Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Add `*Resolved` fields to nested object types containing strongRef/at-uri fields, enabling thread traversal via `reply.parentResolved`. 6 7**Architecture:** Extend `object_builder.gleam` to scan object properties for strongRef/at-uri fields and generate corresponding `*Resolved` fields with resolvers that use the existing DataLoader infrastructure. 8 9**Tech Stack:** Gleam, lexicon_graphql library, swell GraphQL library 10 11--- 12 13## Background 14 15Currently, forward join resolution (`*Resolved` fields) only works at the record level. When a record has a top-level field like `pinnedPost` (strongRef), we generate `pinnedPostResolved`. 16 17For nested object types like `AppBskyFeedPostReplyRef`, the strongRef fields (`parent`, `root`) don't get `*Resolved` fields because: 181. `collection_meta.extract_metadata()` only scans top-level record properties 192. `build_forward_join_fields_with_types()` only runs on record types 203. `object_builder.build_object_type()` doesn't know about forward joins 21 22**Current schema (broken):** 23```graphql 24type AppBskyFeedPostReplyRef { 25 parent: String! # Should be ComAtprotoRepoStrongRef! 26 root: String! # Should be ComAtprotoRepoStrongRef! 27} 28``` 29 30**Desired schema:** 31```graphql 32type AppBskyFeedPostReplyRef { 33 parent: ComAtprotoRepoStrongRef! 34 parentResolved: Record 35 root: ComAtprotoRepoStrongRef! 36 rootResolved: Record 37} 38``` 39 40--- 41 42## Prerequisites 43 44This plan depends on the local ref resolution fix (`2025-12-03-local-ref-resolution.md`) being completed first. That fix ensures `#replyRef` resolves to `AppBskyFeedPostReplyRef` object type instead of `String`. 45 46--- 47 48### Task 1: Add failing test for nested forward join resolution 49 50**Files:** 51- Create: `lexicon_graphql/test/nested_forward_join_test.gleam` 52 53**Step 1: Write the failing test** 54 55```gleam 56/// Tests for nested forward join resolution in schema builder 57/// 58/// Verifies that object types containing strongRef fields get *Resolved fields 59import gleam/dict 60import gleam/option.{None, Some} 61import gleam/string 62import gleeunit/should 63import lexicon_graphql/schema/builder 64import lexicon_graphql/types 65import swell/introspection 66import swell/sdl 67 68/// Test that nested strongRef fields get *Resolved fields 69pub fn nested_strongref_gets_resolved_field_test() { 70 // Create a lexicon with a record that has a nested object containing strongRef 71 let lexicon = 72 types.Lexicon( 73 id: "app.bsky.feed.post", 74 defs: types.Defs( 75 main: Some( 76 types.RecordDef(type_: "record", key: Some("tid"), properties: [ 77 #( 78 "text", 79 types.Property( 80 type_: "string", 81 required: True, 82 format: None, 83 ref: None, 84 refs: None, 85 items: None, 86 ), 87 ), 88 #( 89 "reply", 90 types.Property( 91 type_: "ref", 92 required: False, 93 format: None, 94 ref: Some("#replyRef"), 95 refs: None, 96 items: None, 97 ), 98 ), 99 ]), 100 ), 101 others: dict.from_list([ 102 #( 103 "replyRef", 104 types.Object( 105 types.ObjectDef(type_: "object", required_fields: ["parent", "root"], properties: [ 106 #( 107 "parent", 108 types.Property( 109 type_: "ref", 110 required: True, 111 format: None, 112 ref: Some("com.atproto.repo.strongRef"), 113 refs: None, 114 items: None, 115 ), 116 ), 117 #( 118 "root", 119 types.Property( 120 type_: "ref", 121 required: True, 122 format: None, 123 ref: Some("com.atproto.repo.strongRef"), 124 refs: None, 125 items: None, 126 ), 127 ), 128 ]), 129 ), 130 ), 131 ]), 132 ), 133 ) 134 135 // Also need the strongRef lexicon 136 let strong_ref_lexicon = 137 types.Lexicon( 138 id: "com.atproto.repo.strongRef", 139 defs: types.Defs( 140 main: Some( 141 types.RecordDef(type_: "object", key: None, properties: [ 142 #( 143 "uri", 144 types.Property( 145 type_: "string", 146 required: True, 147 format: Some("at-uri"), 148 ref: None, 149 refs: None, 150 items: None, 151 ), 152 ), 153 #( 154 "cid", 155 types.Property( 156 type_: "string", 157 required: True, 158 format: None, 159 ref: None, 160 refs: None, 161 items: None, 162 ), 163 ), 164 ]), 165 ), 166 others: dict.new(), 167 ), 168 ) 169 170 let result = builder.build_schema([lexicon, strong_ref_lexicon]) 171 should.be_ok(result) 172 173 case result { 174 Ok(schema_val) -> { 175 let all_types = introspection.get_all_schema_types(schema_val) 176 let serialized = sdl.print_types(all_types) 177 178 // The replyRef object type should have parentResolved and rootResolved fields 179 string.contains(serialized, "parentResolved") 180 |> should.be_true 181 182 string.contains(serialized, "rootResolved") 183 |> should.be_true 184 } 185 Error(_) -> should.fail() 186 } 187} 188 189/// Test that at-uri format fields in nested objects also get resolved 190pub fn nested_at_uri_gets_resolved_field_test() { 191 let lexicon = 192 types.Lexicon( 193 id: "test.record", 194 defs: types.Defs( 195 main: Some( 196 types.RecordDef(type_: "record", key: Some("tid"), properties: [ 197 #( 198 "reference", 199 types.Property( 200 type_: "ref", 201 required: False, 202 format: None, 203 ref: Some("#refObject"), 204 refs: None, 205 items: None, 206 ), 207 ), 208 ]), 209 ), 210 others: dict.from_list([ 211 #( 212 "refObject", 213 types.Object( 214 types.ObjectDef(type_: "object", required_fields: ["target"], properties: [ 215 #( 216 "target", 217 types.Property( 218 type_: "string", 219 required: True, 220 format: Some("at-uri"), 221 ref: None, 222 refs: None, 223 items: None, 224 ), 225 ), 226 ]), 227 ), 228 ), 229 ]), 230 ), 231 ) 232 233 let result = builder.build_schema([lexicon]) 234 should.be_ok(result) 235 236 case result { 237 Ok(schema_val) -> { 238 let all_types = introspection.get_all_schema_types(schema_val) 239 let serialized = sdl.print_types(all_types) 240 241 // The refObject type should have targetResolved field 242 string.contains(serialized, "targetResolved") 243 |> should.be_true 244 } 245 Error(_) -> should.fail() 246 } 247} 248``` 249 250**Step 2: Run test to verify it fails** 251 252Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 253 254Expected: FAIL - no `parentResolved` field exists on the nested object type 255 256**Step 3: Commit failing test** 257 258```bash 259git add lexicon_graphql/test/nested_forward_join_test.gleam 260git commit -m "test: add failing tests for nested strongRef resolution" 261``` 262 263--- 264 265### Task 2: Add forward join field identification to object_builder 266 267**Files:** 268- Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 269 270**Step 1: Add ForwardJoinField type and identification function** 271 272Add after the imports (around line 17): 273 274```gleam 275/// Type of forward join field found in an object 276pub type NestedForwardJoinField { 277 NestedStrongRefField(name: String) 278 NestedAtUriField(name: String) 279} 280 281/// Identify forward join fields in object properties 282/// Returns list of fields that can be resolved to other records 283pub fn identify_forward_join_fields( 284 properties: List(#(String, types.Property)), 285) -> List(NestedForwardJoinField) { 286 list.filter_map(properties, fn(prop) { 287 let #(name, property) = prop 288 case property.type_, property.ref, property.format { 289 // strongRef field 290 "ref", option.Some(ref), _ if ref == "com.atproto.repo.strongRef" -> 291 Ok(NestedStrongRefField(name)) 292 // at-uri string field 293 "string", _, option.Some(fmt) if fmt == "at-uri" -> 294 Ok(NestedAtUriField(name)) 295 _, _, _ -> Error(Nil) 296 } 297 }) 298} 299``` 300 301**Step 2: Run build to verify syntax** 302 303Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 304 305Expected: Build succeeds 306 307**Step 3: Commit** 308 309```bash 310git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 311git commit -m "feat(object_builder): add forward join field identification" 312``` 313 314--- 315 316### Task 3: Add parameters for forward join resolution to object_builder 317 318**Files:** 319- Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 320 321**Step 1: Update build_object_type signature** 322 323Update the `build_object_type` function (around line 38) to accept optional batch_fetcher and generic_record_type: 324 325```gleam 326/// Build a GraphQL object type from an ObjectDef 327/// object_types_dict is used to resolve refs to other object types 328/// batch_fetcher and generic_record_type are optional - when provided, *Resolved fields are added 329pub fn build_object_type( 330 obj_def: types.ObjectDef, 331 type_name: String, 332 lexicon_id: String, 333 object_types_dict: Dict(String, schema.Type), 334 batch_fetcher: option.Option(BatchFetcher), 335 generic_record_type: option.Option(schema.Type), 336) -> schema.Type { 337 let lexicon_fields = 338 build_object_fields( 339 obj_def.properties, 340 lexicon_id, 341 object_types_dict, 342 type_name, 343 ) 344 345 // Build forward join fields if we have the necessary dependencies 346 let forward_join_fields = case batch_fetcher, generic_record_type { 347 option.Some(_fetcher), option.Some(record_type) -> { 348 let join_fields = identify_forward_join_fields(obj_def.properties) 349 build_nested_forward_join_fields(join_fields, record_type, batch_fetcher) 350 } 351 _, _ -> [] 352 } 353 354 // Combine regular fields with forward join fields 355 let all_fields = list.append(lexicon_fields, forward_join_fields) 356 357 // GraphQL requires at least one field - add placeholder for empty objects 358 let fields = case all_fields { 359 [] -> [ 360 schema.field( 361 "_", 362 schema.boolean_type(), 363 "Placeholder field for empty object type", 364 fn(_ctx) { Ok(value.Boolean(True)) }, 365 ), 366 ] 367 _ -> all_fields 368 } 369 370 schema.object_type(type_name, "Object type from lexicon definition", fields) 371} 372``` 373 374**Step 2: Add BatchFetcher type alias and import** 375 376Add near the top of the file after imports: 377 378```gleam 379import lexicon_graphql/query/dataloader 380 381/// Batch fetcher type alias for convenience 382pub type BatchFetcher = dataloader.BatchFetcher 383``` 384 385**Step 3: Update build_all_object_types signature** 386 387Update to accept and pass through the new parameters: 388 389```gleam 390/// Build a dict of all object types from the registry 391/// When batch_fetcher and generic_record_type are provided, nested forward joins are enabled 392pub fn build_all_object_types( 393 registry: lexicon_registry.Registry, 394 batch_fetcher: option.Option(BatchFetcher), 395 generic_record_type: option.Option(schema.Type), 396) -> Dict(String, schema.Type) { 397 let object_refs = lexicon_registry.get_all_object_refs(registry) 398 let sorted_refs = sort_refs_dependencies_first(object_refs) 399 400 list.fold(sorted_refs, dict.new(), fn(acc, ref) { 401 case lexicon_registry.get_object_def(registry, ref) { 402 option.Some(obj_def) -> { 403 let type_name = ref_to_type_name(ref) 404 let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 405 let object_type = build_object_type( 406 obj_def, 407 type_name, 408 lexicon_id, 409 acc, 410 batch_fetcher, 411 generic_record_type, 412 ) 413 dict.insert(acc, ref, object_type) 414 } 415 option.None -> acc 416 } 417 }) 418} 419``` 420 421**Step 4: Update internal call site** 422 423Find the call to `build_object_type` inside `build_all_object_types` (around line 163) and update it to pass the new parameters. 424 425**Step 5: Run build (expect failures from callers)** 426 427Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 428 429Expected: Compile errors from callers missing new arguments 430 431**Step 6: Commit WIP** 432 433```bash 434git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 435git commit -m "feat(object_builder): add batch_fetcher and generic_record_type params (WIP)" 436``` 437 438--- 439 440### Task 4: Implement build_nested_forward_join_fields 441 442**Files:** 443- Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 444 445**Step 1: Add the forward join field builder function** 446 447Add after `identify_forward_join_fields`: 448 449```gleam 450/// Build *Resolved fields for nested forward joins 451fn build_nested_forward_join_fields( 452 join_fields: List(NestedForwardJoinField), 453 generic_record_type: schema.Type, 454 batch_fetcher: option.Option(BatchFetcher), 455) -> List(schema.Field) { 456 list.map(join_fields, fn(join_field) { 457 let field_name = case join_field { 458 NestedStrongRefField(name) -> name 459 NestedAtUriField(name) -> name 460 } 461 462 schema.field( 463 field_name <> "Resolved", 464 generic_record_type, 465 "Forward join to referenced record", 466 fn(ctx) { 467 // Extract the field value from the parent object 468 case ctx.data { 469 option.Some(value.Object(fields)) -> { 470 case list.key_find(fields, field_name) { 471 Ok(field_value) -> { 472 // Extract URI using uri_extractor 473 case uri_extractor.extract_uri(value_to_dynamic(field_value)) { 474 option.Some(uri) -> { 475 // Use batch fetcher to resolve the record 476 case batch_fetcher { 477 option.Some(fetcher) -> { 478 case dataloader.batch_fetch_by_uri([uri], fetcher) { 479 Ok(results) -> { 480 case dict.get(results, uri) { 481 Ok(record) -> Ok(record) 482 Error(_) -> Ok(value.Null) 483 } 484 } 485 Error(_) -> Ok(value.Null) 486 } 487 } 488 option.None -> Ok(value.String(uri)) 489 } 490 } 491 option.None -> Ok(value.Null) 492 } 493 } 494 Error(_) -> Ok(value.Null) 495 } 496 } 497 _ -> Ok(value.Null) 498 } 499 }, 500 ) 501 }) 502} 503 504/// Convert a GraphQL Value to Dynamic for uri_extractor 505fn value_to_dynamic(val: value.Value) -> dynamic.Dynamic { 506 // Use the same pattern as dataloader.gleam 507 unsafe_coerce_to_dynamic(val) 508} 509 510@external(erlang, "object_builder_ffi", "identity") 511fn unsafe_coerce_to_dynamic(value: a) -> dynamic.Dynamic 512``` 513 514**Step 2: Add required imports** 515 516Add at the top of the file: 517 518```gleam 519import gleam/dynamic 520import lexicon_graphql/internal/lexicon/uri_extractor 521``` 522 523**Step 3: Create the FFI file** 524 525Create `lexicon_graphql/src/object_builder_ffi.erl`: 526 527```erlang 528-module(object_builder_ffi). 529-export([identity/1]). 530 531identity(X) -> X. 532``` 533 534**Step 4: Run build** 535 536Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 537 538Expected: Still compile errors from callers, but object_builder.gleam should compile 539 540**Step 5: Commit** 541 542```bash 543git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 544git add lexicon_graphql/src/object_builder_ffi.erl 545git commit -m "feat(object_builder): implement nested forward join field builder" 546``` 547 548--- 549 550### Task 5: Update builder.gleam callers 551 552**Files:** 553- Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam` 554 555**Step 1: Find calls to object_builder functions** 556 557Search for `object_builder.build_all_object_types` and `object_builder.build_object_type` calls. 558 559**Step 2: Update calls to pass None for new parameters** 560 561For the basic schema builder (without database), pass `option.None` for both new parameters since there's no batch_fetcher available: 562 563```gleam 564// When calling build_all_object_types: 565object_builder.build_all_object_types(registry, option.None, option.None) 566 567// When calling build_object_type: 568object_builder.build_object_type(obj_def, type_name, lexicon_id, acc, option.None, option.None) 569``` 570 571**Step 3: Run build** 572 573Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 574 575Expected: Build succeeds (or errors from database.gleam) 576 577**Step 4: Commit** 578 579```bash 580git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam 581git commit -m "fix(builder): update object_builder calls with new parameters" 582``` 583 584--- 585 586### Task 6: Update database.gleam with two-pass object type building 587 588**Files:** 589- Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 590 591**Step 1: Identify where object types are built** 592 593Find the section where `ref_object_types` or `object_builder.build_all_object_types` is called. 594 595**Step 2: Implement two-pass build** 596 597The pattern should be: 598 599```gleam 600// Pass 1: Build object types WITHOUT forward joins (no batch_fetcher, no Record union yet) 601let basic_object_types = object_builder.build_all_object_types( 602 registry, 603 option.None, 604 option.None, 605) 606 607// ... build record types and Record union ... 608 609// Pass 2: Rebuild object types WITH forward joins (now we have batch_fetcher and Record union) 610let complete_object_types = object_builder.build_all_object_types( 611 registry, 612 batch_fetcher, 613 option.Some(record_union), 614) 615``` 616 617**Step 3: Update the schema building flow** 618 619Integrate the two-pass approach into the existing multi-pass schema building in `build_schema_with_fetcher`. 620 621**Step 4: Run build** 622 623Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 624 625Expected: Build succeeds 626 627**Step 5: Commit** 628 629```bash 630git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 631git commit -m "feat(database): implement two-pass object type building for nested forward joins" 632``` 633 634--- 635 636### Task 7: Run tests and verify 637 638**Files:** 639- None (verification only) 640 641**Step 1: Run all tests** 642 643Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 644 645Expected: All tests pass including the new `nested_forward_join_test.gleam` 646 647**Step 2: If tests fail, debug** 648 649Check the SDL output to verify the schema has the expected fields: 650 651```gleam 652// Temporary debug in test 653io.println(serialized) 654``` 655 656Look for: 657- `type AppBskyFeedPostReplyRef` exists 658- Has fields: `parent`, `parentResolved`, `root`, `rootResolved` 659- `parentResolved` returns `Record` type 660 661**Step 3: Commit passing state** 662 663```bash 664git add -A 665git commit -m "feat: add *Resolved fields to nested object types with strongRef 666 667Nested object types containing strongRef or at-uri fields now get 668corresponding *Resolved fields that resolve to the actual record. 669 670This enables thread traversal via reply.parentResolved. 671 672- Add identify_forward_join_fields to object_builder 673- Add build_nested_forward_join_fields with resolver logic 674- Implement two-pass object type building in database.gleam 675- Add batch_fetcher and generic_record_type params to object_builder 676 677Example query now works: 678 reply { 679 parentResolved { 680 ... on AppBskyFeedPost { text } 681 } 682 }" 683``` 684 685--- 686 687### Task 8: Add integration test for thread traversal 688 689**Files:** 690- Modify: `server/test/join_integration_test.gleam` 691 692**Step 1: Add test for nested forward join resolution** 693 694Add a new test that: 6951. Creates posts with reply references 6962. Queries `reply.parentResolved` 6973. Verifies the parent post data is returned 698 699```gleam 700pub fn nested_forward_join_resolves_reply_parent_test() { 701 // Create a root post 702 // Create a reply post with reply.parent pointing to root 703 // Query the reply with reply.parentResolved 704 // Verify the root post data is returned 705} 706``` 707 708**Step 2: Run integration tests** 709 710Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 711 712**Step 3: Commit** 713 714```bash 715git add server/test/join_integration_test.gleam 716git commit -m "test: add integration test for nested forward join resolution" 717``` 718 719--- 720 721### Task 9: Verify with MCP introspection 722 723**Files:** 724- None (verification only) 725 726**Step 1: Query the schema** 727 728Use the quickslice MCP to introspect: 729 730```graphql 731{ 732 __type(name: "AppBskyFeedPostReplyRef") { 733 fields { 734 name 735 type { 736 name 737 kind 738 } 739 } 740 } 741} 742``` 743 744Expected fields: 745- `parent: ComAtprotoRepoStrongRef!` 746- `parentResolved: Record` 747- `root: ComAtprotoRepoStrongRef!` 748- `rootResolved: Record` 749 750**Step 2: Test actual resolution** 751 752```graphql 753{ 754 appBskyFeedPost(first: 1, where: { reply: { isNotNull: true } }) { 755 edges { 756 node { 757 text 758 reply { 759 parent { uri cid } 760 parentResolved { 761 ... on AppBskyFeedPost { 762 uri 763 text 764 } 765 } 766 } 767 } 768 } 769 } 770} 771``` 772 773--- 774 775## Summary 776 777| Task | Description | Files | 778|------|-------------|-------| 779| 1 | Add failing tests | `test/nested_forward_join_test.gleam` | 780| 2 | Add field identification | `object_builder.gleam` | 781| 3 | Add new parameters | `object_builder.gleam` | 782| 4 | Implement field builder | `object_builder.gleam`, `object_builder_ffi.erl` | 783| 5 | Update builder.gleam | `builder.gleam` | 784| 6 | Two-pass build in database | `database.gleam` | 785| 7 | Run tests and verify | - | 786| 8 | Add integration test | `join_integration_test.gleam` | 787| 9 | Verify with MCP | - | 788 789## Dependencies 790 791- Requires `2025-12-03-local-ref-resolution.md` to be completed first (so `#replyRef` resolves to object type)