Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
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)