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

Configure Feed

Select the types of activity you want to include in your feed.

fix: resolve strongRef refs in nested object types

Implement 3-phase object type building to properly resolve cross-lexicon
refs in nested object types:

1. First: Main-level object types that have NO local #fragment refs
(like com.atproto.repo.strongRef - pure dependency types)
2. Second: All #fragment refs, which may reference main-level types
3. Third: Main-level types that DO reference their own #fragment refs
(like app.bsky.richtext.facet which refs #mention, #link)

Previously, #replyRef.parent would resolve to String because strongRef
wasn't built yet when processing #fragment types. Now it correctly
resolves to ComAtprotoRepoStrongRef because main-level object types
without local fragments are built first.

+531 -27
+414
dev-docs/plans/2025-12-04-fix-strongref-nested-object-resolution.md
··· 1 + # Fix StrongRef Resolution in Nested Object Types 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix the bug where `com.atproto.repo.strongRef` refs in nested object types (like `#replyRef`) resolve to `String` instead of `ComAtprotoRepoStrongRef`. 6 + 7 + **Architecture:** Reorder schema building phases so main-level object types (like `com.atproto.repo.strongRef`) are built BEFORE nested "others" object types (like `#replyRef`). This ensures strongRef is available in the type dictionary when processing refs inside nested objects. 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql library, swell GraphQL schema 10 + 11 + --- 12 + 13 + ### Task 1: Write Failing Test for StrongRef in Nested Object 14 + 15 + **Files:** 16 + - Create: `lexicon_graphql/test/strongref_nested_resolution_test.gleam` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + Create a test that verifies strongRef fields inside nested "others" object types resolve to the object type, not String. 21 + 22 + ```gleam 23 + /// Tests for strongRef resolution in nested object types 24 + /// 25 + /// Verifies that com.atproto.repo.strongRef refs in "others" object definitions 26 + /// (like #replyRef) resolve to ComAtprotoRepoStrongRef, not String 27 + import gleam/dict 28 + import gleam/list 29 + import gleam/option.{None, Some} 30 + import gleeunit/should 31 + import lexicon_graphql/schema/builder 32 + import lexicon_graphql/types 33 + import swell/schema 34 + 35 + /// Test that strongRef fields in nested objects resolve to object type, not String 36 + /// This reproduces the bug: app.bsky.feed.post#replyRef.parent should be 37 + /// ComAtprotoRepoStrongRef, not String 38 + pub fn strongref_in_nested_object_resolves_to_object_type_test() { 39 + // Create com.atproto.repo.strongRef lexicon (main-level object type) 40 + let strongref_lexicon = 41 + types.Lexicon( 42 + id: "com.atproto.repo.strongRef", 43 + defs: types.Defs( 44 + main: Some( 45 + types.RecordDef(type_: "object", key: None, properties: [ 46 + #( 47 + "uri", 48 + types.Property( 49 + type_: "string", 50 + required: True, 51 + format: Some("at-uri"), 52 + ref: None, 53 + refs: None, 54 + items: None, 55 + ), 56 + ), 57 + #( 58 + "cid", 59 + types.Property( 60 + type_: "string", 61 + required: True, 62 + format: Some("cid"), 63 + ref: None, 64 + refs: None, 65 + items: None, 66 + ), 67 + ), 68 + ]), 69 + ), 70 + others: dict.new(), 71 + ), 72 + ) 73 + 74 + // Create a post lexicon with #replyRef that references strongRef 75 + let post_lexicon = 76 + types.Lexicon( 77 + id: "app.bsky.feed.post", 78 + defs: types.Defs( 79 + main: Some( 80 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 81 + #( 82 + "text", 83 + types.Property( 84 + type_: "string", 85 + required: True, 86 + format: None, 87 + ref: None, 88 + refs: None, 89 + items: None, 90 + ), 91 + ), 92 + #( 93 + "reply", 94 + types.Property( 95 + type_: "ref", 96 + required: False, 97 + format: None, 98 + ref: Some("#replyRef"), 99 + refs: None, 100 + items: None, 101 + ), 102 + ), 103 + ]), 104 + ), 105 + others: dict.from_list([ 106 + #( 107 + "replyRef", 108 + types.Object( 109 + types.ObjectDef( 110 + type_: "object", 111 + required_fields: ["parent", "root"], 112 + properties: [ 113 + #( 114 + "parent", 115 + types.Property( 116 + type_: "ref", 117 + required: True, 118 + format: None, 119 + ref: Some("com.atproto.repo.strongRef"), 120 + refs: None, 121 + items: None, 122 + ), 123 + ), 124 + #( 125 + "root", 126 + types.Property( 127 + type_: "ref", 128 + required: True, 129 + format: None, 130 + ref: Some("com.atproto.repo.strongRef"), 131 + refs: None, 132 + items: None, 133 + ), 134 + ), 135 + ], 136 + ), 137 + ), 138 + ), 139 + ]), 140 + ), 141 + ) 142 + 143 + // Build schema with both lexicons 144 + let result = builder.build_schema([strongref_lexicon, post_lexicon]) 145 + should.be_ok(result) 146 + 147 + let assert Ok(built_schema) = result 148 + let query_type = schema.query_type(built_schema) 149 + let all_types = schema.get_all_types(built_schema) 150 + 151 + // Find the AppBskyFeedPostReplyRef type 152 + let reply_ref_type = 153 + list.find(all_types, fn(t) { 154 + schema.type_name(t) == "AppBskyFeedPostReplyRef" 155 + }) 156 + should.be_ok(reply_ref_type) 157 + 158 + let assert Ok(reply_ref) = reply_ref_type 159 + let fields = schema.get_fields(reply_ref) 160 + 161 + // Find the "parent" field 162 + let parent_field = 163 + list.find(fields, fn(f) { schema.field_name(f) == "parent" }) 164 + should.be_ok(parent_field) 165 + 166 + let assert Ok(parent) = parent_field 167 + let parent_type = schema.field_type(parent) 168 + 169 + // Unwrap NonNull wrapper to get inner type 170 + let inner_type = case schema.inner_type(parent_type) { 171 + Some(t) -> t 172 + None -> parent_type 173 + } 174 + 175 + // The type should be ComAtprotoRepoStrongRef, NOT String 176 + let type_name = schema.type_name(inner_type) 177 + should.not_equal(type_name, "String") 178 + should.equal(type_name, "ComAtprotoRepoStrongRef") 179 + } 180 + ``` 181 + 182 + **Step 2: Run test to verify it fails** 183 + 184 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --only strongref_in_nested_object_resolves_to_object_type_test` 185 + 186 + Expected: FAIL - the `parent` field type will be `String` instead of `ComAtprotoRepoStrongRef` 187 + 188 + **Step 3: Commit the failing test** 189 + 190 + ```bash 191 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 192 + git add test/strongref_nested_resolution_test.gleam 193 + git commit -m "test: add failing test for strongRef resolution in nested objects" 194 + ``` 195 + 196 + --- 197 + 198 + ### Task 2: Reorder Schema Building Phases 199 + 200 + **Files:** 201 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam:43-82` 202 + 203 + **Step 1: Update build_schema function** 204 + 205 + Change the order of operations so `extract_object_type_lexicons` runs FIRST (with an empty dict), then `extract_ref_object_types` runs SECOND (with the object type lexicons available). 206 + 207 + Replace lines 43-82 in `builder.gleam`: 208 + 209 + ```gleam 210 + pub fn build_schema(lexicons: List(Lexicon)) -> Result(schema.Schema, String) { 211 + case lexicons { 212 + [] -> Error("Cannot build schema from empty lexicon list") 213 + _ -> { 214 + // FIRST: Extract object-type lexicons (like com.atproto.repo.strongRef) 215 + // These have no dependencies on "others" types, so build them first 216 + let object_type_lexicons = 217 + extract_object_type_lexicons(lexicons, dict.new()) 218 + 219 + // SECOND: Extract ref object types from lexicon "others" (e.g., #replyRef) 220 + // Now these can resolve refs to object-type lexicons like strongRef 221 + let ref_object_types = 222 + extract_ref_object_types_with_existing(lexicons, object_type_lexicons) 223 + 224 + // Merge object_type_lexicons with ref_object_types 225 + let all_object_types = dict.merge(object_type_lexicons, ref_object_types) 226 + 227 + // Extract record types from lexicons, passing all object types for field resolution 228 + let record_types = extract_record_types(lexicons, all_object_types) 229 + 230 + // Build object types dict including record types 231 + let record_object_types = build_object_types_dict(record_types) 232 + let object_types = dict.merge(all_object_types, record_object_types) 233 + 234 + // Build the query type with fields for each record (not object types) 235 + let query_type = build_query_type(record_types, object_types) 236 + 237 + // Build the mutation type with stub resolvers, using shared object types 238 + let mutation_type = 239 + mutation_builder.build_mutation_type( 240 + lexicons, 241 + object_types, 242 + option.None, 243 + option.None, 244 + option.None, 245 + option.None, 246 + ) 247 + 248 + // Create the schema with queries and mutations 249 + Ok(schema.schema(query_type, option.Some(mutation_type))) 250 + } 251 + } 252 + } 253 + ``` 254 + 255 + **Step 2: Run build to verify it fails (function doesn't exist yet)** 256 + 257 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 258 + 259 + Expected: FAIL - `extract_ref_object_types_with_existing` is not defined 260 + 261 + --- 262 + 263 + ### Task 3: Create extract_ref_object_types_with_existing Function 264 + 265 + **Files:** 266 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam:188-231` 267 + 268 + **Step 1: Add new function that accepts existing types** 269 + 270 + Add this new function after `extract_ref_object_types` (around line 232): 271 + 272 + ```gleam 273 + /// Extract object types from lexicon "others" defs, with pre-existing types available 274 + /// This variant accepts an existing_types dict so refs to object-type lexicons 275 + /// (like com.atproto.repo.strongRef) can be resolved 276 + fn extract_ref_object_types_with_existing( 277 + lexicons: List(Lexicon), 278 + existing_types: dict.Dict(String, schema.Type), 279 + ) -> dict.Dict(String, schema.Type) { 280 + // Collect all object defs with their metadata 281 + let all_defs = 282 + list.flat_map(lexicons, fn(lexicon) { 283 + let types.Lexicon(id, types.Defs(_, others)) = lexicon 284 + dict.to_list(others) 285 + |> list.filter_map(fn(entry) { 286 + let #(def_name, def) = entry 287 + case def { 288 + types.Object(obj_def) -> { 289 + let full_ref = id <> "#" <> def_name 290 + Ok(#(full_ref, id, obj_def)) 291 + } 292 + types.Record(_) -> Error(Nil) 293 + } 294 + }) 295 + }) 296 + 297 + // Partition into types with refs and types without refs 298 + let #(with_refs, without_refs) = 299 + list.partition(all_defs, fn(entry) { 300 + let #(_, _, obj_def) = entry 301 + has_ref_properties(obj_def) 302 + }) 303 + 304 + // First pass: build leaf types (no ref dependencies) 305 + // Start with existing_types so we can resolve external refs 306 + let leaf_types = 307 + list.fold(without_refs, existing_types, fn(acc, entry) { 308 + let #(full_ref, lexicon_id, obj_def) = entry 309 + let object_type = 310 + build_others_object_type(full_ref, lexicon_id, obj_def, acc) 311 + dict.insert(acc, full_ref, object_type) 312 + }) 313 + 314 + // Second pass: build types that have refs (can now resolve to leaf types AND existing types) 315 + let all_types = 316 + list.fold(with_refs, leaf_types, fn(acc, entry) { 317 + let #(full_ref, lexicon_id, obj_def) = entry 318 + let object_type = 319 + build_others_object_type(full_ref, lexicon_id, obj_def, acc) 320 + dict.insert(acc, full_ref, object_type) 321 + }) 322 + 323 + // Return only the newly built types (exclude existing_types keys) 324 + dict.filter(all_types, fn(key, _value) { 325 + case dict.get(existing_types, key) { 326 + Ok(_) -> False 327 + Error(_) -> True 328 + } 329 + }) 330 + } 331 + ``` 332 + 333 + **Step 2: Run build to verify it compiles** 334 + 335 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 336 + 337 + Expected: SUCCESS - should compile without errors 338 + 339 + **Step 3: Run the test** 340 + 341 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test -- --only strongref_in_nested_object_resolves_to_object_type_test` 342 + 343 + Expected: PASS - the `parent` field should now be `ComAtprotoRepoStrongRef` 344 + 345 + **Step 4: Run all tests to check for regressions** 346 + 347 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 348 + 349 + Expected: All tests pass 350 + 351 + **Step 5: Commit the fix** 352 + 353 + ```bash 354 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 355 + git add src/lexicon_graphql/schema/builder.gleam 356 + git commit -m "fix: resolve strongRef refs in nested object types 357 + 358 + Reorder schema building phases so object-type lexicons (like 359 + com.atproto.repo.strongRef) are built before 'others' object types 360 + (like #replyRef). This ensures strongRef is available in the type 361 + dictionary when processing refs inside nested objects. 362 + 363 + Previously, #replyRef.parent would resolve to String because strongRef 364 + wasn't built yet. Now it correctly resolves to ComAtprotoRepoStrongRef." 365 + ``` 366 + 367 + --- 368 + 369 + ### Task 4: Verify with MCP Server 370 + 371 + **Step 1: Run the quickslice server and verify the fix** 372 + 373 + Use the quickslice MCP to introspect the schema and verify `AppBskyFeedPostReplyRef` now has correct types: 374 + 375 + ```graphql 376 + { 377 + __type(name: "AppBskyFeedPostReplyRef") { 378 + fields { 379 + name 380 + type { 381 + name 382 + kind 383 + ofType { 384 + name 385 + kind 386 + } 387 + } 388 + } 389 + } 390 + } 391 + ``` 392 + 393 + Expected: `parent` and `root` fields should have type `ComAtprotoRepoStrongRef` (not `String`) 394 + 395 + **Step 2: Squash commits if desired** 396 + 397 + ```bash 398 + git rebase -i HEAD~2 399 + # Squash the test commit into the fix commit 400 + ``` 401 + 402 + --- 403 + 404 + ## Summary 405 + 406 + This fix changes the schema building order from: 407 + 1. `extract_ref_object_types()` - builds "others" ❌ strongRef not available 408 + 2. `extract_object_type_lexicons()` - builds main object types 409 + 410 + To: 411 + 1. `extract_object_type_lexicons()` - builds main object types FIRST 412 + 2. `extract_ref_object_types_with_existing()` - builds "others" ✅ strongRef available 413 + 414 + The key insight is that object-type lexicons (type: "object" at main level) have no dependencies on "others" types, so they can safely be built first. Then "others" types can resolve refs to both sibling "others" types AND main object types.
+117 -27
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
··· 271 271 /// Build a dict of all object types from the registry 272 272 /// Keys are the fully-qualified refs (e.g., "social.grain.defs#aspectRatio") 273 273 /// 274 - /// Note: This builds types recursively. Object types that reference other object types 275 - /// will have those refs resolved using the same dict (which gets built incrementally). 274 + /// Note: This builds types in three phases: 275 + /// 1. First: Main-level object types that have NO local #fragment refs in properties 276 + /// (like com.atproto.repo.strongRef - pure dependency types) 277 + /// 2. Second: All #fragment refs, which may reference main-level types from phase 1 278 + /// 3. Third: Main-level types that DO reference their own #fragment refs 279 + /// (like app.bsky.richtext.facet which refs #mention, #link) 280 + /// 276 281 /// When batch_fetcher and generic_record_type are provided, nested forward joins are enabled 277 282 pub fn build_all_object_types( 278 283 registry: lexicon_registry.Registry, ··· 281 286 ) -> Dict(String, schema.Type) { 282 287 let object_refs = lexicon_registry.get_all_object_refs(registry) 283 288 284 - // Sort refs so #fragment refs are built before main refs 285 - // This ensures union member types exist when main types reference them 286 - let sorted_refs = sort_refs_dependencies_first(object_refs) 289 + // Partition refs into main-level (without #) and fragment refs (with #) 290 + let #(fragment_refs, main_refs) = 291 + list.partition(object_refs, fn(ref) { string.contains(ref, "#") }) 287 292 288 - // Build all object types in dependency order 289 - list.fold(sorted_refs, dict.new(), fn(acc, ref) { 290 - case lexicon_registry.get_object_def(registry, ref) { 291 - option.Some(obj_def) -> { 292 - // Generate a GraphQL type name from the ref 293 - // e.g., "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio" 294 - let type_name = ref_to_type_name(ref) 295 - let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 296 - // Pass acc as the object_types_dict so we can resolve refs to previously built types 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 - ) 306 - dict.insert(acc, ref, object_type) 307 - } 308 - option.None -> acc 309 - } 293 + // Further partition main refs into those with/without local #fragment refs 294 + let #(main_with_fragments, main_without_fragments) = 295 + list.partition(main_refs, fn(ref) { has_local_fragment_refs(registry, ref) }) 296 + 297 + // PHASE 1: Build main-level types that have NO local #fragment refs 298 + // These are pure dependency types like com.atproto.repo.strongRef 299 + let phase1_types = 300 + list.fold(main_without_fragments, dict.new(), fn(acc, ref) { 301 + build_single_object_type( 302 + registry, 303 + ref, 304 + acc, 305 + batch_fetcher, 306 + generic_record_type, 307 + ) 308 + }) 309 + 310 + // PHASE 2: Build all #fragment refs with main-level types available 311 + // Sort so nested fragments are built before parent fragments if needed 312 + let sorted_fragments = sort_refs_dependencies_first(fragment_refs) 313 + let phase2_types = 314 + list.fold(sorted_fragments, phase1_types, fn(acc, ref) { 315 + build_single_object_type( 316 + registry, 317 + ref, 318 + acc, 319 + batch_fetcher, 320 + generic_record_type, 321 + ) 322 + }) 323 + 324 + // PHASE 3: Build main-level types that reference their own #fragments 325 + // Now their #fragment refs can be resolved 326 + list.fold(main_with_fragments, phase2_types, fn(acc, ref) { 327 + build_single_object_type( 328 + registry, 329 + ref, 330 + acc, 331 + batch_fetcher, 332 + generic_record_type, 333 + ) 310 334 }) 335 + } 336 + 337 + /// Check if a main-level ref has any local #fragment refs in its properties 338 + fn has_local_fragment_refs( 339 + registry: lexicon_registry.Registry, 340 + ref: String, 341 + ) -> Bool { 342 + case lexicon_registry.get_object_def(registry, ref) { 343 + option.Some(obj_def) -> { 344 + list.any(obj_def.properties, fn(prop) { 345 + let #(_, property) = prop 346 + // Check for refs that start with # (local fragment refs) 347 + let has_direct_fragment_ref = case property.ref { 348 + option.Some(r) -> string.starts_with(r, "#") 349 + option.None -> False 350 + } 351 + let has_items_fragment_ref = case property.items { 352 + option.Some(items) -> { 353 + let has_item_ref = case items.ref { 354 + option.Some(r) -> string.starts_with(r, "#") 355 + option.None -> False 356 + } 357 + let has_refs = case items.refs { 358 + option.Some(refs) -> 359 + list.any(refs, fn(r) { string.starts_with(r, "#") }) 360 + option.None -> False 361 + } 362 + has_item_ref || has_refs 363 + } 364 + option.None -> False 365 + } 366 + has_direct_fragment_ref || has_items_fragment_ref 367 + }) 368 + } 369 + option.None -> False 370 + } 371 + } 372 + 373 + /// Build a single object type and add it to the accumulator dict 374 + fn build_single_object_type( 375 + registry: lexicon_registry.Registry, 376 + ref: String, 377 + acc: Dict(String, schema.Type), 378 + batch_fetcher: option.Option(BatchFetcher), 379 + generic_record_type: option.Option(schema.Type), 380 + ) -> Dict(String, schema.Type) { 381 + case lexicon_registry.get_object_def(registry, ref) { 382 + option.Some(obj_def) -> { 383 + // Generate a GraphQL type name from the ref 384 + // e.g., "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio" 385 + let type_name = ref_to_type_name(ref) 386 + let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 387 + // Pass acc as the object_types_dict so we can resolve refs to previously built types 388 + let object_type = 389 + build_object_type( 390 + obj_def, 391 + type_name, 392 + lexicon_id, 393 + acc, 394 + batch_fetcher, 395 + generic_record_type, 396 + ) 397 + dict.insert(acc, ref, object_type) 398 + } 399 + option.None -> acc 400 + } 311 401 } 312 402 313 403 /// Convert a ref to a GraphQL type name