Nested StrongRef Resolution Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add *Resolved fields to nested object types containing strongRef/at-uri fields, enabling thread traversal via reply.parentResolved.
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.
Tech Stack: Gleam, lexicon_graphql library, swell GraphQL library
Background#
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.
For nested object types like AppBskyFeedPostReplyRef, the strongRef fields (parent, root) don't get *Resolved fields because:
collection_meta.extract_metadata()only scans top-level record propertiesbuild_forward_join_fields_with_types()only runs on record typesobject_builder.build_object_type()doesn't know about forward joins
Current schema (broken):
type AppBskyFeedPostReplyRef {
parent: String! # Should be ComAtprotoRepoStrongRef!
root: String! # Should be ComAtprotoRepoStrongRef!
}
Desired schema:
type AppBskyFeedPostReplyRef {
parent: ComAtprotoRepoStrongRef!
parentResolved: Record
root: ComAtprotoRepoStrongRef!
rootResolved: Record
}
Prerequisites#
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.
Task 1: Add failing test for nested forward join resolution#
Files:
- Create:
lexicon_graphql/test/nested_forward_join_test.gleam
Step 1: Write the failing test
/// Tests for nested forward join resolution in schema builder
///
/// Verifies that object types containing strongRef fields get *Resolved fields
import gleam/dict
import gleam/option.{None, Some}
import gleam/string
import gleeunit/should
import lexicon_graphql/schema/builder
import lexicon_graphql/types
import swell/introspection
import swell/sdl
/// Test that nested strongRef fields get *Resolved fields
pub fn nested_strongref_gets_resolved_field_test() {
// Create a lexicon with a record that has a nested object containing strongRef
let lexicon =
types.Lexicon(
id: "app.bsky.feed.post",
defs: types.Defs(
main: Some(
types.RecordDef(type_: "record", key: Some("tid"), properties: [
#(
"text",
types.Property(
type_: "string",
required: True,
format: None,
ref: None,
refs: None,
items: None,
),
),
#(
"reply",
types.Property(
type_: "ref",
required: False,
format: None,
ref: Some("#replyRef"),
refs: None,
items: None,
),
),
]),
),
others: dict.from_list([
#(
"replyRef",
types.Object(
types.ObjectDef(type_: "object", required_fields: ["parent", "root"], properties: [
#(
"parent",
types.Property(
type_: "ref",
required: True,
format: None,
ref: Some("com.atproto.repo.strongRef"),
refs: None,
items: None,
),
),
#(
"root",
types.Property(
type_: "ref",
required: True,
format: None,
ref: Some("com.atproto.repo.strongRef"),
refs: None,
items: None,
),
),
]),
),
),
]),
),
)
// Also need the strongRef lexicon
let strong_ref_lexicon =
types.Lexicon(
id: "com.atproto.repo.strongRef",
defs: types.Defs(
main: Some(
types.RecordDef(type_: "object", key: None, properties: [
#(
"uri",
types.Property(
type_: "string",
required: True,
format: Some("at-uri"),
ref: None,
refs: None,
items: None,
),
),
#(
"cid",
types.Property(
type_: "string",
required: True,
format: None,
ref: None,
refs: None,
items: None,
),
),
]),
),
others: dict.new(),
),
)
let result = builder.build_schema([lexicon, strong_ref_lexicon])
should.be_ok(result)
case result {
Ok(schema_val) -> {
let all_types = introspection.get_all_schema_types(schema_val)
let serialized = sdl.print_types(all_types)
// The replyRef object type should have parentResolved and rootResolved fields
string.contains(serialized, "parentResolved")
|> should.be_true
string.contains(serialized, "rootResolved")
|> should.be_true
}
Error(_) -> should.fail()
}
}
/// Test that at-uri format fields in nested objects also get resolved
pub fn nested_at_uri_gets_resolved_field_test() {
let lexicon =
types.Lexicon(
id: "test.record",
defs: types.Defs(
main: Some(
types.RecordDef(type_: "record", key: Some("tid"), properties: [
#(
"reference",
types.Property(
type_: "ref",
required: False,
format: None,
ref: Some("#refObject"),
refs: None,
items: None,
),
),
]),
),
others: dict.from_list([
#(
"refObject",
types.Object(
types.ObjectDef(type_: "object", required_fields: ["target"], properties: [
#(
"target",
types.Property(
type_: "string",
required: True,
format: Some("at-uri"),
ref: None,
refs: None,
items: None,
),
),
]),
),
),
]),
),
)
let result = builder.build_schema([lexicon])
should.be_ok(result)
case result {
Ok(schema_val) -> {
let all_types = introspection.get_all_schema_types(schema_val)
let serialized = sdl.print_types(all_types)
// The refObject type should have targetResolved field
string.contains(serialized, "targetResolved")
|> should.be_true
}
Error(_) -> should.fail()
}
}
Step 2: Run test to verify it fails
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test
Expected: FAIL - no parentResolved field exists on the nested object type
Step 3: Commit failing test
git add lexicon_graphql/test/nested_forward_join_test.gleam
git commit -m "test: add failing tests for nested strongRef resolution"
Task 2: Add forward join field identification to object_builder#
Files:
- Modify:
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
Step 1: Add ForwardJoinField type and identification function
Add after the imports (around line 17):
/// Type of forward join field found in an object
pub type NestedForwardJoinField {
NestedStrongRefField(name: String)
NestedAtUriField(name: String)
}
/// Identify forward join fields in object properties
/// Returns list of fields that can be resolved to other records
pub fn identify_forward_join_fields(
properties: List(#(String, types.Property)),
) -> List(NestedForwardJoinField) {
list.filter_map(properties, fn(prop) {
let #(name, property) = prop
case property.type_, property.ref, property.format {
// strongRef field
"ref", option.Some(ref), _ if ref == "com.atproto.repo.strongRef" ->
Ok(NestedStrongRefField(name))
// at-uri string field
"string", _, option.Some(fmt) if fmt == "at-uri" ->
Ok(NestedAtUriField(name))
_, _, _ -> Error(Nil)
}
})
}
Step 2: Run build to verify syntax
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build
Expected: Build succeeds
Step 3: Commit
git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
git commit -m "feat(object_builder): add forward join field identification"
Task 3: Add parameters for forward join resolution to object_builder#
Files:
- Modify:
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
Step 1: Update build_object_type signature
Update the build_object_type function (around line 38) to accept optional batch_fetcher and generic_record_type:
/// Build a GraphQL object type from an ObjectDef
/// object_types_dict is used to resolve refs to other object types
/// batch_fetcher and generic_record_type are optional - when provided, *Resolved fields are added
pub fn build_object_type(
obj_def: types.ObjectDef,
type_name: String,
lexicon_id: String,
object_types_dict: Dict(String, schema.Type),
batch_fetcher: option.Option(BatchFetcher),
generic_record_type: option.Option(schema.Type),
) -> schema.Type {
let lexicon_fields =
build_object_fields(
obj_def.properties,
lexicon_id,
object_types_dict,
type_name,
)
// Build forward join fields if we have the necessary dependencies
let forward_join_fields = case batch_fetcher, generic_record_type {
option.Some(_fetcher), option.Some(record_type) -> {
let join_fields = identify_forward_join_fields(obj_def.properties)
build_nested_forward_join_fields(join_fields, record_type, batch_fetcher)
}
_, _ -> []
}
// Combine regular fields with forward join fields
let all_fields = list.append(lexicon_fields, forward_join_fields)
// GraphQL requires at least one field - add placeholder for empty objects
let fields = case all_fields {
[] -> [
schema.field(
"_",
schema.boolean_type(),
"Placeholder field for empty object type",
fn(_ctx) { Ok(value.Boolean(True)) },
),
]
_ -> all_fields
}
schema.object_type(type_name, "Object type from lexicon definition", fields)
}
Step 2: Add BatchFetcher type alias and import
Add near the top of the file after imports:
import lexicon_graphql/query/dataloader
/// Batch fetcher type alias for convenience
pub type BatchFetcher = dataloader.BatchFetcher
Step 3: Update build_all_object_types signature
Update to accept and pass through the new parameters:
/// Build a dict of all object types from the registry
/// When batch_fetcher and generic_record_type are provided, nested forward joins are enabled
pub fn build_all_object_types(
registry: lexicon_registry.Registry,
batch_fetcher: option.Option(BatchFetcher),
generic_record_type: option.Option(schema.Type),
) -> Dict(String, schema.Type) {
let object_refs = lexicon_registry.get_all_object_refs(registry)
let sorted_refs = sort_refs_dependencies_first(object_refs)
list.fold(sorted_refs, dict.new(), fn(acc, ref) {
case lexicon_registry.get_object_def(registry, ref) {
option.Some(obj_def) -> {
let type_name = ref_to_type_name(ref)
let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref)
let object_type = build_object_type(
obj_def,
type_name,
lexicon_id,
acc,
batch_fetcher,
generic_record_type,
)
dict.insert(acc, ref, object_type)
}
option.None -> acc
}
})
}
Step 4: Update internal call site
Find the call to build_object_type inside build_all_object_types (around line 163) and update it to pass the new parameters.
Step 5: Run build (expect failures from callers)
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build
Expected: Compile errors from callers missing new arguments
Step 6: Commit WIP
git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
git commit -m "feat(object_builder): add batch_fetcher and generic_record_type params (WIP)"
Task 4: Implement build_nested_forward_join_fields#
Files:
- Modify:
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
Step 1: Add the forward join field builder function
Add after identify_forward_join_fields:
/// Build *Resolved fields for nested forward joins
fn build_nested_forward_join_fields(
join_fields: List(NestedForwardJoinField),
generic_record_type: schema.Type,
batch_fetcher: option.Option(BatchFetcher),
) -> List(schema.Field) {
list.map(join_fields, fn(join_field) {
let field_name = case join_field {
NestedStrongRefField(name) -> name
NestedAtUriField(name) -> name
}
schema.field(
field_name <> "Resolved",
generic_record_type,
"Forward join to referenced record",
fn(ctx) {
// Extract the field value from the parent object
case ctx.data {
option.Some(value.Object(fields)) -> {
case list.key_find(fields, field_name) {
Ok(field_value) -> {
// Extract URI using uri_extractor
case uri_extractor.extract_uri(value_to_dynamic(field_value)) {
option.Some(uri) -> {
// Use batch fetcher to resolve the record
case batch_fetcher {
option.Some(fetcher) -> {
case dataloader.batch_fetch_by_uri([uri], fetcher) {
Ok(results) -> {
case dict.get(results, uri) {
Ok(record) -> Ok(record)
Error(_) -> Ok(value.Null)
}
}
Error(_) -> Ok(value.Null)
}
}
option.None -> Ok(value.String(uri))
}
}
option.None -> Ok(value.Null)
}
}
Error(_) -> Ok(value.Null)
}
}
_ -> Ok(value.Null)
}
},
)
})
}
/// Convert a GraphQL Value to Dynamic for uri_extractor
fn value_to_dynamic(val: value.Value) -> dynamic.Dynamic {
// Use the same pattern as dataloader.gleam
unsafe_coerce_to_dynamic(val)
}
@external(erlang, "object_builder_ffi", "identity")
fn unsafe_coerce_to_dynamic(value: a) -> dynamic.Dynamic
Step 2: Add required imports
Add at the top of the file:
import gleam/dynamic
import lexicon_graphql/internal/lexicon/uri_extractor
Step 3: Create the FFI file
Create lexicon_graphql/src/object_builder_ffi.erl:
-module(object_builder_ffi).
-export([identity/1]).
identity(X) -> X.
Step 4: Run build
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build
Expected: Still compile errors from callers, but object_builder.gleam should compile
Step 5: Commit
git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
git add lexicon_graphql/src/object_builder_ffi.erl
git commit -m "feat(object_builder): implement nested forward join field builder"
Task 5: Update builder.gleam callers#
Files:
- Modify:
lexicon_graphql/src/lexicon_graphql/schema/builder.gleam
Step 1: Find calls to object_builder functions
Search for object_builder.build_all_object_types and object_builder.build_object_type calls.
Step 2: Update calls to pass None for new parameters
For the basic schema builder (without database), pass option.None for both new parameters since there's no batch_fetcher available:
// When calling build_all_object_types:
object_builder.build_all_object_types(registry, option.None, option.None)
// When calling build_object_type:
object_builder.build_object_type(obj_def, type_name, lexicon_id, acc, option.None, option.None)
Step 3: Run build
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build
Expected: Build succeeds (or errors from database.gleam)
Step 4: Commit
git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam
git commit -m "fix(builder): update object_builder calls with new parameters"
Task 6: Update database.gleam with two-pass object type building#
Files:
- Modify:
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
Step 1: Identify where object types are built
Find the section where ref_object_types or object_builder.build_all_object_types is called.
Step 2: Implement two-pass build
The pattern should be:
// Pass 1: Build object types WITHOUT forward joins (no batch_fetcher, no Record union yet)
let basic_object_types = object_builder.build_all_object_types(
registry,
option.None,
option.None,
)
// ... build record types and Record union ...
// Pass 2: Rebuild object types WITH forward joins (now we have batch_fetcher and Record union)
let complete_object_types = object_builder.build_all_object_types(
registry,
batch_fetcher,
option.Some(record_union),
)
Step 3: Update the schema building flow
Integrate the two-pass approach into the existing multi-pass schema building in build_schema_with_fetcher.
Step 4: Run build
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build
Expected: Build succeeds
Step 5: Commit
git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam
git commit -m "feat(database): implement two-pass object type building for nested forward joins"
Task 7: Run tests and verify#
Files:
- None (verification only)
Step 1: Run all tests
Run: cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test
Expected: All tests pass including the new nested_forward_join_test.gleam
Step 2: If tests fail, debug
Check the SDL output to verify the schema has the expected fields:
// Temporary debug in test
io.println(serialized)
Look for:
type AppBskyFeedPostReplyRefexists- Has fields:
parent,parentResolved,root,rootResolved parentResolvedreturnsRecordtype
Step 3: Commit passing state
git add -A
git commit -m "feat: add *Resolved fields to nested object types with strongRef
Nested object types containing strongRef or at-uri fields now get
corresponding *Resolved fields that resolve to the actual record.
This enables thread traversal via reply.parentResolved.
- Add identify_forward_join_fields to object_builder
- Add build_nested_forward_join_fields with resolver logic
- Implement two-pass object type building in database.gleam
- Add batch_fetcher and generic_record_type params to object_builder
Example query now works:
reply {
parentResolved {
... on AppBskyFeedPost { text }
}
}"
Task 8: Add integration test for thread traversal#
Files:
- Modify:
server/test/join_integration_test.gleam
Step 1: Add test for nested forward join resolution
Add a new test that:
- Creates posts with reply references
- Queries
reply.parentResolved - Verifies the parent post data is returned
pub fn nested_forward_join_resolves_reply_parent_test() {
// Create a root post
// Create a reply post with reply.parent pointing to root
// Query the reply with reply.parentResolved
// Verify the root post data is returned
}
Step 2: Run integration tests
Run: cd /Users/chadmiller/code/quickslice/server && gleam test
Step 3: Commit
git add server/test/join_integration_test.gleam
git commit -m "test: add integration test for nested forward join resolution"
Task 9: Verify with MCP introspection#
Files:
- None (verification only)
Step 1: Query the schema
Use the quickslice MCP to introspect:
{
__type(name: "AppBskyFeedPostReplyRef") {
fields {
name
type {
name
kind
}
}
}
}
Expected fields:
parent: ComAtprotoRepoStrongRef!parentResolved: Recordroot: ComAtprotoRepoStrongRef!rootResolved: Record
Step 2: Test actual resolution
{
appBskyFeedPost(first: 1, where: { reply: { isNotNull: true } }) {
edges {
node {
text
reply {
parent { uri cid }
parentResolved {
... on AppBskyFeedPost {
uri
text
}
}
}
}
}
}
}
Summary#
| Task | Description | Files |
|---|---|---|
| 1 | Add failing tests | test/nested_forward_join_test.gleam |
| 2 | Add field identification | object_builder.gleam |
| 3 | Add new parameters | object_builder.gleam |
| 4 | Implement field builder | object_builder.gleam, object_builder_ffi.erl |
| 5 | Update builder.gleam | builder.gleam |
| 6 | Two-pass build in database | database.gleam |
| 7 | Run tests and verify | - |
| 8 | Add integration test | join_integration_test.gleam |
| 9 | Verify with MCP | - |
Dependencies#
- Requires
2025-12-03-local-ref-resolution.mdto be completed first (so#replyRefresolves to object type)