Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat(database): implement nested forward join resolution for strongRef fields

Add support for resolving nested strongRef fields within object types by
implementing a two-pass object type building approach:

- First pass builds ref_object_types with forward joins before record types
- Second pass adds *Resolved fields to nested object types with strongRef
- Add forward join field identification in object_builder
- Add batch_fetcher and generic_record_type params to object_builder

Includes integration tests for nested forward join resolution.

+1480 -11
+791
dev-docs/plans/2025-12-03-nested-strongref-resolution.md
··· 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 + 15 + Currently, 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 + 17 + For nested object types like `AppBskyFeedPostReplyRef`, the strongRef fields (`parent`, `root`) don't get `*Resolved` fields because: 18 + 1. `collection_meta.extract_metadata()` only scans top-level record properties 19 + 2. `build_forward_join_fields_with_types()` only runs on record types 20 + 3. `object_builder.build_object_type()` doesn't know about forward joins 21 + 22 + **Current schema (broken):** 23 + ```graphql 24 + type AppBskyFeedPostReplyRef { 25 + parent: String! # Should be ComAtprotoRepoStrongRef! 26 + root: String! # Should be ComAtprotoRepoStrongRef! 27 + } 28 + ``` 29 + 30 + **Desired schema:** 31 + ```graphql 32 + type AppBskyFeedPostReplyRef { 33 + parent: ComAtprotoRepoStrongRef! 34 + parentResolved: Record 35 + root: ComAtprotoRepoStrongRef! 36 + rootResolved: Record 37 + } 38 + ``` 39 + 40 + --- 41 + 42 + ## Prerequisites 43 + 44 + This 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 59 + import gleam/dict 60 + import gleam/option.{None, Some} 61 + import gleam/string 62 + import gleeunit/should 63 + import lexicon_graphql/schema/builder 64 + import lexicon_graphql/types 65 + import swell/introspection 66 + import swell/sdl 67 + 68 + /// Test that nested strongRef fields get *Resolved fields 69 + pub 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 190 + pub 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 + 252 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 253 + 254 + Expected: FAIL - no `parentResolved` field exists on the nested object type 255 + 256 + **Step 3: Commit failing test** 257 + 258 + ```bash 259 + git add lexicon_graphql/test/nested_forward_join_test.gleam 260 + git 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 + 272 + Add after the imports (around line 17): 273 + 274 + ```gleam 275 + /// Type of forward join field found in an object 276 + pub 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 283 + pub 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 + 303 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 304 + 305 + Expected: Build succeeds 306 + 307 + **Step 3: Commit** 308 + 309 + ```bash 310 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 311 + git 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 + 323 + Update 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 329 + pub 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 + 376 + Add near the top of the file after imports: 377 + 378 + ```gleam 379 + import lexicon_graphql/query/dataloader 380 + 381 + /// Batch fetcher type alias for convenience 382 + pub type BatchFetcher = dataloader.BatchFetcher 383 + ``` 384 + 385 + **Step 3: Update build_all_object_types signature** 386 + 387 + Update 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 392 + pub 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 + 423 + Find 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 + 427 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 428 + 429 + Expected: Compile errors from callers missing new arguments 430 + 431 + **Step 6: Commit WIP** 432 + 433 + ```bash 434 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 435 + git 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 + 447 + Add after `identify_forward_join_fields`: 448 + 449 + ```gleam 450 + /// Build *Resolved fields for nested forward joins 451 + fn 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 505 + fn 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") 511 + fn unsafe_coerce_to_dynamic(value: a) -> dynamic.Dynamic 512 + ``` 513 + 514 + **Step 2: Add required imports** 515 + 516 + Add at the top of the file: 517 + 518 + ```gleam 519 + import gleam/dynamic 520 + import lexicon_graphql/internal/lexicon/uri_extractor 521 + ``` 522 + 523 + **Step 3: Create the FFI file** 524 + 525 + Create `lexicon_graphql/src/object_builder_ffi.erl`: 526 + 527 + ```erlang 528 + -module(object_builder_ffi). 529 + -export([identity/1]). 530 + 531 + identity(X) -> X. 532 + ``` 533 + 534 + **Step 4: Run build** 535 + 536 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 537 + 538 + Expected: Still compile errors from callers, but object_builder.gleam should compile 539 + 540 + **Step 5: Commit** 541 + 542 + ```bash 543 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 544 + git add lexicon_graphql/src/object_builder_ffi.erl 545 + git 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 + 557 + Search 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 + 561 + For 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: 565 + object_builder.build_all_object_types(registry, option.None, option.None) 566 + 567 + // When calling build_object_type: 568 + object_builder.build_object_type(obj_def, type_name, lexicon_id, acc, option.None, option.None) 569 + ``` 570 + 571 + **Step 3: Run build** 572 + 573 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 574 + 575 + Expected: Build succeeds (or errors from database.gleam) 576 + 577 + **Step 4: Commit** 578 + 579 + ```bash 580 + git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam 581 + git 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 + 593 + Find the section where `ref_object_types` or `object_builder.build_all_object_types` is called. 594 + 595 + **Step 2: Implement two-pass build** 596 + 597 + The pattern should be: 598 + 599 + ```gleam 600 + // Pass 1: Build object types WITHOUT forward joins (no batch_fetcher, no Record union yet) 601 + let 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) 610 + let 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 + 619 + Integrate the two-pass approach into the existing multi-pass schema building in `build_schema_with_fetcher`. 620 + 621 + **Step 4: Run build** 622 + 623 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 624 + 625 + Expected: Build succeeds 626 + 627 + **Step 5: Commit** 628 + 629 + ```bash 630 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 631 + git 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 + 643 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 644 + 645 + Expected: All tests pass including the new `nested_forward_join_test.gleam` 646 + 647 + **Step 2: If tests fail, debug** 648 + 649 + Check the SDL output to verify the schema has the expected fields: 650 + 651 + ```gleam 652 + // Temporary debug in test 653 + io.println(serialized) 654 + ``` 655 + 656 + Look 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 664 + git add -A 665 + git commit -m "feat: add *Resolved fields to nested object types with strongRef 666 + 667 + Nested object types containing strongRef or at-uri fields now get 668 + corresponding *Resolved fields that resolve to the actual record. 669 + 670 + This 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 + 677 + Example 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 + 694 + Add a new test that: 695 + 1. Creates posts with reply references 696 + 2. Queries `reply.parentResolved` 697 + 3. Verifies the parent post data is returned 698 + 699 + ```gleam 700 + pub 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 + 710 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 711 + 712 + **Step 3: Commit** 713 + 714 + ```bash 715 + git add server/test/join_integration_test.gleam 716 + git 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 + 728 + Use 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 + 744 + Expected 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)
+142 -3
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
··· 4 4 /// Used for nested object types like aspectRatio that are defined 5 5 /// as refs in lexicons (e.g., "social.grain.defs#aspectRatio") 6 6 import gleam/dict.{type Dict} 7 + import gleam/dynamic.{type Dynamic} 7 8 import gleam/list 8 9 import gleam/option 9 10 import gleam/order ··· 11 12 import lexicon_graphql/internal/graphql/type_mapper 12 13 import lexicon_graphql/internal/lexicon/nsid 13 14 import lexicon_graphql/internal/lexicon/registry as lexicon_registry 15 + import lexicon_graphql/internal/lexicon/uri_extractor 16 + import lexicon_graphql/query/dataloader 14 17 import lexicon_graphql/types 15 18 import swell/schema 16 19 import swell/value 17 20 21 + /// Batch fetcher type alias for convenience 22 + pub type BatchFetcher = 23 + dataloader.BatchFetcher 24 + 25 + /// Type of forward join field found in an object 26 + pub type NestedForwardJoinField { 27 + NestedStrongRefField(name: String) 28 + NestedAtUriField(name: String) 29 + } 30 + 31 + /// Identify forward join fields in object properties 32 + /// Returns list of fields that can be resolved to other records 33 + pub fn identify_forward_join_fields( 34 + properties: List(#(String, types.Property)), 35 + ) -> List(NestedForwardJoinField) { 36 + list.filter_map(properties, fn(prop) { 37 + let #(name, property) = prop 38 + case property.type_, property.ref, property.format { 39 + // strongRef field 40 + "ref", option.Some(ref), _ if ref == "com.atproto.repo.strongRef" -> 41 + Ok(NestedStrongRefField(name)) 42 + // at-uri string field 43 + "string", _, option.Some(fmt) if fmt == "at-uri" -> 44 + Ok(NestedAtUriField(name)) 45 + _, _, _ -> Error(Nil) 46 + } 47 + }) 48 + } 49 + 50 + /// Build *Resolved fields for nested forward joins 51 + fn build_nested_forward_join_fields( 52 + join_fields: List(NestedForwardJoinField), 53 + generic_record_type: schema.Type, 54 + batch_fetcher: option.Option(BatchFetcher), 55 + ) -> List(schema.Field) { 56 + list.map(join_fields, fn(join_field) { 57 + let field_name = case join_field { 58 + NestedStrongRefField(name) -> name 59 + NestedAtUriField(name) -> name 60 + } 61 + 62 + schema.field( 63 + field_name <> "Resolved", 64 + generic_record_type, 65 + "Forward join to referenced record", 66 + fn(ctx) { 67 + // Extract the field value from the parent object 68 + case ctx.data { 69 + option.Some(value.Object(fields)) -> { 70 + case list.key_find(fields, field_name) { 71 + Ok(field_value) -> { 72 + // Extract URI using uri_extractor 73 + case uri_extractor.extract_uri(value_to_dynamic(field_value)) { 74 + option.Some(uri) -> { 75 + // Use batch fetcher to resolve the record 76 + case batch_fetcher { 77 + option.Some(fetcher) -> { 78 + case dataloader.batch_fetch_by_uri([uri], fetcher) { 79 + Ok(results) -> { 80 + case dict.get(results, uri) { 81 + Ok(record) -> Ok(record) 82 + Error(_) -> Ok(value.Null) 83 + } 84 + } 85 + Error(_) -> Ok(value.Null) 86 + } 87 + } 88 + option.None -> Ok(value.String(uri)) 89 + } 90 + } 91 + option.None -> Ok(value.Null) 92 + } 93 + } 94 + Error(_) -> Ok(value.Null) 95 + } 96 + } 97 + _ -> Ok(value.Null) 98 + } 99 + }, 100 + ) 101 + }) 102 + } 103 + 104 + /// Convert a GraphQL Value to Dynamic for uri_extractor 105 + fn value_to_dynamic(val: value.Value) -> Dynamic { 106 + case val { 107 + value.String(s) -> unsafe_coerce_to_dynamic(s) 108 + value.Int(i) -> unsafe_coerce_to_dynamic(i) 109 + value.Float(f) -> unsafe_coerce_to_dynamic(f) 110 + value.Boolean(b) -> unsafe_coerce_to_dynamic(b) 111 + value.Null -> unsafe_coerce_to_dynamic(option.None) 112 + value.Object(fields) -> { 113 + let field_map = 114 + list.fold(fields, dict.new(), fn(acc, field) { 115 + let #(key, field_val) = field 116 + dict.insert(acc, key, value_to_dynamic(field_val)) 117 + }) 118 + unsafe_coerce_to_dynamic(field_map) 119 + } 120 + value.List(items) -> { 121 + let converted = list.map(items, value_to_dynamic) 122 + unsafe_coerce_to_dynamic(converted) 123 + } 124 + value.Enum(name) -> unsafe_coerce_to_dynamic(name) 125 + } 126 + } 127 + 128 + @external(erlang, "object_builder_ffi", "identity") 129 + fn unsafe_coerce_to_dynamic(value: a) -> Dynamic 130 + 18 131 /// Sort refs so that #fragment refs come before main refs 19 132 /// This ensures dependencies are built first 20 133 /// e.g., "app.bsky.richtext.facet#mention" before "app.bsky.richtext.facet" ··· 35 148 36 149 /// Build a GraphQL object type from an ObjectDef 37 150 /// object_types_dict is used to resolve refs to other object types 151 + /// batch_fetcher and generic_record_type are optional - when provided, *Resolved fields are added 38 152 pub fn build_object_type( 39 153 obj_def: types.ObjectDef, 40 154 type_name: String, 41 155 lexicon_id: String, 42 156 object_types_dict: Dict(String, schema.Type), 157 + batch_fetcher: option.Option(BatchFetcher), 158 + generic_record_type: option.Option(schema.Type), 43 159 ) -> schema.Type { 44 160 let lexicon_fields = 45 161 build_object_fields( ··· 49 165 type_name, 50 166 ) 51 167 168 + // Build forward join fields if we have the necessary dependencies 169 + let forward_join_fields = case batch_fetcher, generic_record_type { 170 + option.Some(_fetcher), option.Some(record_type) -> { 171 + let join_fields = identify_forward_join_fields(obj_def.properties) 172 + build_nested_forward_join_fields(join_fields, record_type, batch_fetcher) 173 + } 174 + _, _ -> [] 175 + } 176 + 177 + // Combine regular fields with forward join fields 178 + let all_fields = list.append(lexicon_fields, forward_join_fields) 179 + 52 180 // GraphQL requires at least one field - add placeholder for empty objects 53 - let fields = case lexicon_fields { 181 + let fields = case all_fields { 54 182 [] -> [ 55 183 schema.field( 56 184 "_", ··· 59 187 fn(_ctx) { Ok(value.Boolean(True)) }, 60 188 ), 61 189 ] 62 - _ -> lexicon_fields 190 + _ -> all_fields 63 191 } 64 192 65 193 schema.object_type(type_name, "Object type from lexicon definition", fields) ··· 145 273 /// 146 274 /// Note: This builds types recursively. Object types that reference other object types 147 275 /// will have those refs resolved using the same dict (which gets built incrementally). 276 + /// When batch_fetcher and generic_record_type are provided, nested forward joins are enabled 148 277 pub fn build_all_object_types( 149 278 registry: lexicon_registry.Registry, 279 + batch_fetcher: option.Option(BatchFetcher), 280 + generic_record_type: option.Option(schema.Type), 150 281 ) -> Dict(String, schema.Type) { 151 282 let object_refs = lexicon_registry.get_all_object_refs(registry) 152 283 ··· 163 294 let type_name = ref_to_type_name(ref) 164 295 let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 165 296 // Pass acc as the object_types_dict so we can resolve refs to previously built types 166 - let object_type = build_object_type(obj_def, type_name, lexicon_id, acc) 297 + let object_type = 298 + build_object_type( 299 + obj_def, 300 + type_name, 301 + lexicon_id, 302 + acc, 303 + batch_fetcher, 304 + generic_record_type, 305 + ) 167 306 dict.insert(acc, ref, object_type) 168 307 } 169 308 option.None -> acc
+52 -6
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 267 267 dict.Dict(String, dict.Dict(String, FieldType)), 268 268 ) { 269 269 // ============================================================================= 270 - // PASS 0: Build object types from lexicon defs 270 + // PASS 0: Build object types from lexicon defs (without forward joins) 271 271 // ============================================================================= 272 272 273 273 // Create a registry from all lexicons 274 274 let registry = lexicon_registry.from_lexicons(lexicons) 275 275 276 - // Build all object types from defs (e.g., social.grain.defs#aspectRatio) 277 - let ref_object_types = object_type_builder.build_all_object_types(registry) 276 + // Build all object types from defs WITHOUT forward joins first 277 + // This is needed to build record types and determine the Record union 278 + let ref_object_types_initial = 279 + object_type_builder.build_all_object_types( 280 + registry, 281 + option.None, 282 + option.None, 283 + ) 278 284 279 285 // ============================================================================= 280 286 // PASS 1: Extract metadata and build basic types ··· 301 307 // Build DID join map: source_nsid -> List(#(target_nsid, target_meta)) 302 308 let did_join_map = build_did_join_map(metadata_list) 303 309 304 - // Parse lexicons to create basic RecordTypes (base fields only, no joins yet) 310 + // Parse lexicons to create TEMPORARY RecordTypes (just to build Record union) 311 + let temp_record_types = 312 + lexicons 313 + |> list.filter_map(fn(lex) { 314 + parse_lexicon_with_reverse_joins( 315 + lex, 316 + [], 317 + batch_fetcher, 318 + ref_object_types_initial, 319 + ) 320 + }) 321 + 322 + // Build temporary object types to create Record union 323 + let temp_object_types = 324 + list.fold(temp_record_types, dict.new(), fn(acc, record_type) { 325 + let object_type = 326 + schema.object_type( 327 + record_type.type_name, 328 + "Record type: " <> record_type.nsid, 329 + record_type.fields, 330 + ) 331 + dict.insert(acc, record_type.nsid, object_type) 332 + }) 333 + 334 + // Build Record union from temporary object types 335 + let temp_possible_types = dict.values(temp_object_types) 336 + let temp_record_union = build_record_union(temp_possible_types) 337 + 338 + // ============================================================================= 339 + // PASS 0b: Rebuild object types WITH forward joins using the Record union 340 + // ============================================================================= 341 + // Now that we have the Record union, rebuild ref_object_types WITH 342 + // nested forward join fields (parentResolved, rootResolved, etc.) 343 + let ref_object_types = 344 + object_type_builder.build_all_object_types( 345 + registry, 346 + batch_fetcher, 347 + option.Some(temp_record_union), 348 + ) 349 + 350 + // Now parse lexicons AGAIN with the ref_object_types that have forward join fields 305 351 let basic_record_types_without_forward_joins = 306 352 lexicons 307 353 |> list.filter_map(fn(lex) { ··· 544 590 final_record_union, 545 591 ) 546 592 547 - // Merge ref_object_types (from lexicon defs) into final_object_types 548 - // This makes object types like "social.grain.defs#aspectRatio" available for ref resolution 593 + // Merge ref_object_types (from lexicon defs, already has forward joins from PASS 0b) 594 + // into final_object_types to make them available for ref resolution 549 595 let final_object_types_with_refs = 550 596 dict.fold(ref_object_types, final_object_types, fn(acc, ref, obj_type) { 551 597 dict.insert(acc, ref, obj_type)
+6
lexicon_graphql/src/object_builder_ffi.erl
··· 1 + -module(object_builder_ffi). 2 + -export([identity/1]). 3 + 4 + %% Identity function - returns value unchanged 5 + %% In Erlang, everything is already "dynamic", so this just passes through 6 + identity(Value) -> Value.
+225
lexicon_graphql/test/nested_forward_join_test.gleam
··· 1 + /// Tests for nested forward join resolution in object_builder 2 + /// 3 + /// Verifies that object types containing strongRef fields get *Resolved fields 4 + /// when batch_fetcher and generic_record_type are provided 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleeunit/should 9 + import lexicon_graphql/internal/graphql/object_builder 10 + import lexicon_graphql/internal/lexicon/registry 11 + import lexicon_graphql/types 12 + import swell/schema 13 + 14 + /// Test that nested strongRef fields get *Resolved fields when batch_fetcher is provided 15 + pub fn nested_strongref_gets_resolved_field_test() { 16 + // Create a lexicon with an object type containing strongRef fields 17 + let lexicon = 18 + types.Lexicon( 19 + id: "app.bsky.feed.post", 20 + defs: types.Defs( 21 + main: Some( 22 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 23 + #( 24 + "text", 25 + types.Property( 26 + type_: "string", 27 + required: True, 28 + format: None, 29 + ref: None, 30 + refs: None, 31 + items: None, 32 + ), 33 + ), 34 + ]), 35 + ), 36 + others: dict.from_list([ 37 + #( 38 + "replyRef", 39 + types.Object( 40 + types.ObjectDef( 41 + type_: "object", 42 + required_fields: ["parent", "root"], 43 + properties: [ 44 + #( 45 + "parent", 46 + types.Property( 47 + type_: "ref", 48 + required: True, 49 + format: None, 50 + ref: Some("com.atproto.repo.strongRef"), 51 + refs: None, 52 + items: None, 53 + ), 54 + ), 55 + #( 56 + "root", 57 + types.Property( 58 + type_: "ref", 59 + required: True, 60 + format: None, 61 + ref: Some("com.atproto.repo.strongRef"), 62 + refs: None, 63 + items: None, 64 + ), 65 + ), 66 + ], 67 + ), 68 + ), 69 + ), 70 + ]), 71 + ), 72 + ) 73 + 74 + // Build a registry from the lexicon 75 + let reg = registry.from_lexicons([lexicon]) 76 + 77 + // Create a mock Record union type (the generic type for *Resolved fields) 78 + let mock_record_type = schema.object_type("Record", "Mock record union", []) 79 + 80 + // Create a mock batch fetcher that returns empty results 81 + let mock_batch_fetcher = fn(_uris, _collection, _field) { Ok(dict.new()) } 82 + 83 + // Build object types WITH batch_fetcher and generic_record_type 84 + let object_types = 85 + object_builder.build_all_object_types( 86 + reg, 87 + Some(mock_batch_fetcher), 88 + Some(mock_record_type), 89 + ) 90 + 91 + // The replyRef object type should exist 92 + let assert Ok(reply_ref_type) = 93 + dict.get(object_types, "app.bsky.feed.post#replyRef") 94 + 95 + // Get the fields from the type 96 + let fields = schema.get_fields(reply_ref_type) 97 + let field_names = list.map(fields, schema.field_name) 98 + 99 + // Should have parentResolved and rootResolved fields 100 + list.contains(field_names, "parentResolved") 101 + |> should.be_true 102 + 103 + list.contains(field_names, "rootResolved") 104 + |> should.be_true 105 + } 106 + 107 + /// Test that at-uri format fields in nested objects also get *Resolved fields 108 + pub fn nested_at_uri_gets_resolved_field_test() { 109 + let lexicon = 110 + types.Lexicon( 111 + id: "test.record", 112 + defs: types.Defs( 113 + main: Some( 114 + types.RecordDef(type_: "record", key: Some("tid"), properties: []), 115 + ), 116 + others: dict.from_list([ 117 + #( 118 + "refObject", 119 + types.Object( 120 + types.ObjectDef( 121 + type_: "object", 122 + required_fields: ["target"], 123 + properties: [ 124 + #( 125 + "target", 126 + types.Property( 127 + type_: "string", 128 + required: True, 129 + format: Some("at-uri"), 130 + ref: None, 131 + refs: None, 132 + items: None, 133 + ), 134 + ), 135 + ], 136 + ), 137 + ), 138 + ), 139 + ]), 140 + ), 141 + ) 142 + 143 + // Build a registry from the lexicon 144 + let reg = registry.from_lexicons([lexicon]) 145 + 146 + // Create mock types 147 + let mock_record_type = schema.object_type("Record", "Mock record union", []) 148 + let mock_batch_fetcher = fn(_uris, _collection, _field) { Ok(dict.new()) } 149 + 150 + // Build object types WITH batch_fetcher and generic_record_type 151 + let object_types = 152 + object_builder.build_all_object_types( 153 + reg, 154 + Some(mock_batch_fetcher), 155 + Some(mock_record_type), 156 + ) 157 + 158 + // The refObject type should exist 159 + let assert Ok(ref_object_type) = 160 + dict.get(object_types, "test.record#refObject") 161 + 162 + // Get the fields from the type 163 + let fields = schema.get_fields(ref_object_type) 164 + let field_names = list.map(fields, schema.field_name) 165 + 166 + // Should have targetResolved field 167 + list.contains(field_names, "targetResolved") 168 + |> should.be_true 169 + } 170 + 171 + /// Test that *Resolved fields are NOT added when batch_fetcher is None 172 + pub fn no_resolved_fields_without_batch_fetcher_test() { 173 + let lexicon = 174 + types.Lexicon( 175 + id: "app.bsky.feed.post", 176 + defs: types.Defs( 177 + main: Some( 178 + types.RecordDef(type_: "record", key: Some("tid"), properties: []), 179 + ), 180 + others: dict.from_list([ 181 + #( 182 + "replyRef", 183 + types.Object( 184 + types.ObjectDef( 185 + type_: "object", 186 + required_fields: ["parent"], 187 + properties: [ 188 + #( 189 + "parent", 190 + types.Property( 191 + type_: "ref", 192 + required: True, 193 + format: None, 194 + ref: Some("com.atproto.repo.strongRef"), 195 + refs: None, 196 + items: None, 197 + ), 198 + ), 199 + ], 200 + ), 201 + ), 202 + ), 203 + ]), 204 + ), 205 + ) 206 + 207 + let reg = registry.from_lexicons([lexicon]) 208 + 209 + // Build object types WITHOUT batch_fetcher (None, None) 210 + let object_types = object_builder.build_all_object_types(reg, None, None) 211 + 212 + let assert Ok(reply_ref_type) = 213 + dict.get(object_types, "app.bsky.feed.post#replyRef") 214 + 215 + let fields = schema.get_fields(reply_ref_type) 216 + let field_names = list.map(fields, schema.field_name) 217 + 218 + // Should NOT have parentResolved field (no batch_fetcher) 219 + list.contains(field_names, "parentResolved") 220 + |> should.be_false 221 + 222 + // Should still have the regular parent field 223 + list.contains(field_names, "parent") 224 + |> should.be_true 225 + }
+1 -1
lexicon_graphql/test/object_build_order_test.gleam
··· 91 91 92 92 // Build registry and object types 93 93 let reg = registry.from_lexicons([lexicon]) 94 - let object_types = object_builder.build_all_object_types(reg) 94 + let object_types = object_builder.build_all_object_types(reg, None, None) 95 95 96 96 // The main type should exist 97 97 let main_type_result = dict.get(object_types, "app.bsky.richtext.facet")
+1 -1
lexicon_graphql/test/union_resolver_test.gleam
··· 85 85 86 86 // Build registry and object types 87 87 let reg = registry.from_lexicons([lexicon]) 88 - let object_types = object_builder.build_all_object_types(reg) 88 + let object_types = object_builder.build_all_object_types(reg, None, None) 89 89 90 90 // Get the main facet type 91 91 let assert Ok(main_type) = dict.get(object_types, "app.bsky.richtext.facet")
+262
server/test/join_integration_test.gleam
··· 1268 1268 // Note: To truly verify batching, we'd need to instrument the database 1269 1269 // layer to count queries. For now, this test ensures correctness. 1270 1270 } 1271 + 1272 + // Helper to create a post lexicon with nested reply object containing strongRef fields 1273 + fn create_post_lexicon_with_nested_reply() -> String { 1274 + json.object([ 1275 + #("lexicon", json.int(1)), 1276 + #("id", json.string("app.bsky.feed.post")), 1277 + #( 1278 + "defs", 1279 + json.object([ 1280 + #( 1281 + "main", 1282 + json.object([ 1283 + #("type", json.string("record")), 1284 + #("key", json.string("tid")), 1285 + #( 1286 + "record", 1287 + json.object([ 1288 + #("type", json.string("object")), 1289 + #( 1290 + "required", 1291 + json.array([json.string("text")], of: fn(x) { x }), 1292 + ), 1293 + #( 1294 + "properties", 1295 + json.object([ 1296 + #( 1297 + "text", 1298 + json.object([ 1299 + #("type", json.string("string")), 1300 + #("maxLength", json.int(300)), 1301 + ]), 1302 + ), 1303 + #( 1304 + "reply", 1305 + json.object([ 1306 + #("type", json.string("ref")), 1307 + #("ref", json.string("#replyRef")), 1308 + ]), 1309 + ), 1310 + ]), 1311 + ), 1312 + ]), 1313 + ), 1314 + ]), 1315 + ), 1316 + #( 1317 + "replyRef", 1318 + json.object([ 1319 + #("type", json.string("object")), 1320 + #( 1321 + "required", 1322 + json.array( 1323 + [json.string("parent"), json.string("root")], 1324 + of: fn(x) { x }, 1325 + ), 1326 + ), 1327 + #( 1328 + "properties", 1329 + json.object([ 1330 + #( 1331 + "parent", 1332 + json.object([ 1333 + #("type", json.string("ref")), 1334 + #("ref", json.string("com.atproto.repo.strongRef")), 1335 + ]), 1336 + ), 1337 + #( 1338 + "root", 1339 + json.object([ 1340 + #("type", json.string("ref")), 1341 + #("ref", json.string("com.atproto.repo.strongRef")), 1342 + ]), 1343 + ), 1344 + ]), 1345 + ), 1346 + ]), 1347 + ), 1348 + ]), 1349 + ), 1350 + ]) 1351 + |> json.to_string 1352 + } 1353 + 1354 + // Test: Nested forward join resolution through reply.parentResolved 1355 + pub fn nested_forward_join_resolves_reply_parent_test() { 1356 + // Setup database 1357 + let assert Ok(db) = sqlight.open(":memory:") 1358 + let assert Ok(_) = tables.create_lexicon_table(db) 1359 + let assert Ok(_) = tables.create_record_table(db) 1360 + let assert Ok(_) = tables.create_actor_table(db) 1361 + 1362 + // Insert lexicon with nested reply object 1363 + let post_lexicon = create_post_lexicon_with_nested_reply() 1364 + let assert Ok(_) = lexicons.insert(db, "app.bsky.feed.post", post_lexicon) 1365 + 1366 + // Insert root post 1367 + let root_uri = "at://did:plc:root123/app.bsky.feed.post/root1" 1368 + let root_json = 1369 + json.object([#("text", json.string("This is the root post"))]) 1370 + |> json.to_string 1371 + 1372 + let assert Ok(_) = 1373 + records.insert( 1374 + db, 1375 + root_uri, 1376 + "cid_root", 1377 + "did:plc:root123", 1378 + "app.bsky.feed.post", 1379 + root_json, 1380 + ) 1381 + 1382 + // Insert parent post (reply to root) 1383 + let parent_uri = "at://did:plc:parent456/app.bsky.feed.post/parent1" 1384 + let parent_json = 1385 + json.object([ 1386 + #("text", json.string("This is a reply to the root")), 1387 + #( 1388 + "reply", 1389 + json.object([ 1390 + #( 1391 + "parent", 1392 + json.object([ 1393 + #("uri", json.string(root_uri)), 1394 + #("cid", json.string("cid_root")), 1395 + ]), 1396 + ), 1397 + #( 1398 + "root", 1399 + json.object([ 1400 + #("uri", json.string(root_uri)), 1401 + #("cid", json.string("cid_root")), 1402 + ]), 1403 + ), 1404 + ]), 1405 + ), 1406 + ]) 1407 + |> json.to_string 1408 + 1409 + let assert Ok(_) = 1410 + records.insert( 1411 + db, 1412 + parent_uri, 1413 + "cid_parent", 1414 + "did:plc:parent456", 1415 + "app.bsky.feed.post", 1416 + parent_json, 1417 + ) 1418 + 1419 + // Insert reply post (reply to parent) 1420 + let reply_uri = "at://did:plc:user789/app.bsky.feed.post/reply1" 1421 + let reply_json = 1422 + json.object([ 1423 + #("text", json.string("This is a reply to the parent")), 1424 + #( 1425 + "reply", 1426 + json.object([ 1427 + #( 1428 + "parent", 1429 + json.object([ 1430 + #("uri", json.string(parent_uri)), 1431 + #("cid", json.string("cid_parent")), 1432 + ]), 1433 + ), 1434 + #( 1435 + "root", 1436 + json.object([ 1437 + #("uri", json.string(root_uri)), 1438 + #("cid", json.string("cid_root")), 1439 + ]), 1440 + ), 1441 + ]), 1442 + ), 1443 + ]) 1444 + |> json.to_string 1445 + 1446 + let assert Ok(_) = 1447 + records.insert( 1448 + db, 1449 + reply_uri, 1450 + "cid_reply", 1451 + "did:plc:user789", 1452 + "app.bsky.feed.post", 1453 + reply_json, 1454 + ) 1455 + 1456 + // Execute GraphQL query that uses nested forward join: reply.parentResolved 1457 + let query = 1458 + " 1459 + { 1460 + appBskyFeedPost { 1461 + edges { 1462 + node { 1463 + uri 1464 + text 1465 + reply { 1466 + parent { 1467 + uri 1468 + cid 1469 + } 1470 + parentResolved { 1471 + ... on AppBskyFeedPost { 1472 + uri 1473 + text 1474 + } 1475 + } 1476 + root { 1477 + uri 1478 + cid 1479 + } 1480 + rootResolved { 1481 + ... on AppBskyFeedPost { 1482 + uri 1483 + text 1484 + } 1485 + } 1486 + } 1487 + } 1488 + } 1489 + } 1490 + } 1491 + " 1492 + 1493 + let assert Ok(cache) = did_cache.start() 1494 + let assert Ok(response_json) = 1495 + graphql_gleam.execute_query_with_db( 1496 + db, 1497 + query, 1498 + "{}", 1499 + Error(Nil), 1500 + cache, 1501 + option.None, 1502 + "https://plc.directory", 1503 + ) 1504 + 1505 + // Verify the nested forward joins work correctly 1506 + // The reply post should have its parent resolved 1507 + string.contains(response_json, reply_uri) 1508 + |> should.be_true 1509 + 1510 + string.contains(response_json, "This is a reply to the parent") 1511 + |> should.be_true 1512 + 1513 + // The parentResolved field should contain the parent post 1514 + string.contains(response_json, "parentResolved") 1515 + |> should.be_true 1516 + 1517 + string.contains(response_json, parent_uri) 1518 + |> should.be_true 1519 + 1520 + string.contains(response_json, "This is a reply to the root") 1521 + |> should.be_true 1522 + 1523 + // The rootResolved field should contain the root post 1524 + string.contains(response_json, "rootResolved") 1525 + |> should.be_true 1526 + 1527 + string.contains(response_json, root_uri) 1528 + |> should.be_true 1529 + 1530 + string.contains(response_json, "This is the root post") 1531 + |> should.be_true 1532 + }