Auto-indexing service and GraphQL API for AT Protocol Records

fix: resolve union types using canonical type registry (swell 2.1.2)

When querying through forward join *Resolved fields, reverse joins like
socialGrainPhotoExifViaPhoto are now available on the resolved types.

The fix in swell 2.1.2 makes the executor use a canonical type registry
(built from introspection's deduplicated types) when resolving union types,
ensuring the most complete version of each type is used.

Adds test verifying Record union members have reverse join fields.

+681 -6
+443
dev-docs/plans/2025-12-12-pass4-forward-join-union-fix.md
··· 1 + # PASS 4: Fix Forward Join Fields to Use Final Record Union 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix reverse joins not being available when querying through `*Resolved` forward join fields on the Record union type. 6 + 7 + **Architecture:** Add a PASS 4 to the schema builder that rebuilds forward join fields after `final_record_union` is constructed, ensuring `*Resolved` fields return the complete Record union with all reverse/DID join fields. 8 + 9 + **Tech Stack:** Gleam, swell GraphQL library 10 + 11 + --- 12 + 13 + ## Problem Summary 14 + 15 + When querying through `itemResolved` (or any `*Resolved` forward join field), reverse joins like `socialGrainPhotoExifViaPhoto` are not available: 16 + 17 + ```graphql 18 + query { 19 + socialGrainGalleryItemViaGallery { 20 + edges { 21 + node { 22 + itemResolved { 23 + ... on SocialGrainPhoto { 24 + socialGrainPhotoExifViaPhoto { # Returns null - field not found! 25 + edges { node { exposureTime } } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + ``` 34 + 35 + **Root cause:** In PASS 3, forward join fields are built using `complete_object_types` which contains `complete_record_union` from PASS 2. But `complete_record_union` was built from types that used `basic_object_types` - they don't have the final reverse join fields. 36 + 37 + --- 38 + 39 + ## Task 1: Write Failing Test 40 + 41 + **Files:** 42 + - Create: `lexicon_graphql/test/forward_join_reverse_join_test.gleam` 43 + 44 + **Step 1: Write the failing test** 45 + 46 + ```gleam 47 + /// Tests that reverse join fields are available through forward join resolution 48 + /// 49 + /// This tests the fix for PASS 4 where *Resolved fields need to return 50 + /// the Record union with complete types including reverse joins. 51 + import gleam/dict 52 + import gleam/list 53 + import gleam/option.{None, Some} 54 + import gleam/string 55 + import gleeunit 56 + import gleeunit/should 57 + import lexicon_graphql/schema/database as db_schema_builder 58 + import lexicon_graphql/types 59 + import swell/introspection 60 + import swell/schema 61 + import swell/sdl 62 + 63 + pub fn main() { 64 + gleeunit.main() 65 + } 66 + 67 + // Helper to create a test schema with a mock fetcher 68 + fn create_test_schema_from_lexicons( 69 + lexicons: List(types.Lexicon), 70 + ) -> schema.Schema { 71 + let fetcher = fn(_collection, _params) { 72 + Ok(#([], option.None, False, False, option.None)) 73 + } 74 + 75 + case 76 + db_schema_builder.build_schema_with_fetcher( 77 + lexicons, 78 + fetcher, 79 + option.None, 80 + option.None, 81 + option.None, 82 + option.None, 83 + option.None, 84 + option.None, 85 + ) 86 + { 87 + Ok(s) -> s 88 + Error(_) -> panic as "Failed to build test schema" 89 + } 90 + } 91 + 92 + /// Test that the Record union members have reverse join fields 93 + /// 94 + /// Setup: 95 + /// - Photo collection (target of forward join) 96 + /// - Exif collection with forward join to Photo (creates reverse join on Photo) 97 + /// - GalleryItem collection with forward join to Photo (item field) 98 + /// 99 + /// Expected: When resolving itemResolved on GalleryItem, the Photo type 100 + /// should have socialGrainPhotoExifViaPhoto reverse join available 101 + pub fn record_union_members_have_reverse_joins_test() { 102 + // Photo collection - will be target of both forward and reverse joins 103 + let photo_lexicon = 104 + types.Lexicon( 105 + id: "social.grain.photo", 106 + defs: types.Defs( 107 + main: Some( 108 + types.RecordDef(type_: "record", key: None, properties: [ 109 + #( 110 + "title", 111 + types.Property( 112 + type_: "string", 113 + required: False, 114 + format: None, 115 + ref: None, 116 + refs: None, 117 + items: None, 118 + ), 119 + ), 120 + ]), 121 + ), 122 + others: dict.new(), 123 + ), 124 + ) 125 + 126 + // Exif collection - has forward join to Photo (creates reverse join) 127 + let exif_lexicon = 128 + types.Lexicon( 129 + id: "social.grain.photo.exif", 130 + defs: types.Defs( 131 + main: Some( 132 + types.RecordDef(type_: "record", key: None, properties: [ 133 + #( 134 + "photo", 135 + types.Property( 136 + type_: "string", 137 + required: True, 138 + format: Some("at-uri"), 139 + ref: None, 140 + refs: None, 141 + items: None, 142 + ), 143 + ), 144 + #( 145 + "exposureTime", 146 + types.Property( 147 + type_: "integer", 148 + required: False, 149 + format: None, 150 + ref: None, 151 + refs: None, 152 + items: None, 153 + ), 154 + ), 155 + ]), 156 + ), 157 + others: dict.new(), 158 + ), 159 + ) 160 + 161 + // GalleryItem collection - has forward join to Photo via item field 162 + let gallery_item_lexicon = 163 + types.Lexicon( 164 + id: "social.grain.gallery.item", 165 + defs: types.Defs( 166 + main: Some( 167 + types.RecordDef(type_: "record", key: None, properties: [ 168 + #( 169 + "item", 170 + types.Property( 171 + type_: "string", 172 + required: True, 173 + format: Some("at-uri"), 174 + ref: None, 175 + refs: None, 176 + items: None, 177 + ), 178 + ), 179 + ]), 180 + ), 181 + others: dict.new(), 182 + ), 183 + ) 184 + 185 + let test_schema = 186 + create_test_schema_from_lexicons([ 187 + photo_lexicon, 188 + exif_lexicon, 189 + gallery_item_lexicon, 190 + ]) 191 + 192 + // Get all types and serialize to SDL 193 + let all_types = introspection.get_all_schema_types(test_schema) 194 + let serialized = sdl.print_types(all_types) 195 + 196 + // Verify Photo type has the reverse join from Exif 197 + // This confirms the direct query works 198 + string.contains(serialized, "socialGrainPhotoExifViaPhoto") 199 + |> should.be_true 200 + 201 + // Verify GalleryItem has itemResolved field 202 + string.contains(serialized, "itemResolved: Record") 203 + |> should.be_true 204 + 205 + // Now the key test: The Record union's SocialGrainPhoto member 206 + // should have the reverse join field 207 + // 208 + // Find the Record union definition and check its members have reverse joins 209 + // The SDL should show SocialGrainPhoto in the union with all its fields 210 + 211 + // Get the Record union type 212 + let record_union = 213 + list.find(all_types, fn(t) { schema.type_name(t) == "Record" }) 214 + 215 + case record_union { 216 + Ok(union_type) -> { 217 + // Get the possible types (union members) 218 + let possible_types = schema.get_possible_types(union_type) 219 + 220 + // Find the Photo type in the union 221 + let photo_type = 222 + list.find(possible_types, fn(t) { 223 + schema.type_name(t) == "SocialGrainPhoto" 224 + }) 225 + 226 + case photo_type { 227 + Ok(photo) -> { 228 + // Get the fields of the Photo type within the union 229 + let fields = schema.get_fields(photo) 230 + 231 + // Check that socialGrainPhotoExifViaPhoto is present 232 + let has_reverse_join = 233 + list.any(fields, fn(f) { 234 + schema.field_name(f) == "socialGrainPhotoExifViaPhoto" 235 + }) 236 + 237 + has_reverse_join |> should.be_true 238 + } 239 + Error(_) -> { 240 + // Photo type not found in union - fail 241 + should.fail() 242 + } 243 + } 244 + } 245 + Error(_) -> { 246 + // Record union not found - fail 247 + should.fail() 248 + } 249 + } 250 + } 251 + ``` 252 + 253 + **Step 2: Run test to verify it fails** 254 + 255 + Run: `cd lexicon_graphql && gleam test -- --filter="record_union_members_have_reverse_joins"` 256 + 257 + Expected: FAIL - the Photo type within the Record union does not have `socialGrainPhotoExifViaPhoto` field 258 + 259 + --- 260 + 261 + ## Task 2: Implement PASS 4 262 + 263 + **Files:** 264 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam:591-600` 265 + 266 + **Step 1: Add PASS 4 after final_record_union is built** 267 + 268 + Insert the following code after line 591 (after `final_object_types` is created) and before the `// Merge ref_object_types` comment: 269 + 270 + ```gleam 271 + // ============================================================================= 272 + // PASS 4: Rebuild forward join fields to use final_record_union 273 + // ============================================================================= 274 + // The forward join fields built in PASS 3 reference complete_record_union, 275 + // which contains types without the final reverse/DID joins. We need to rebuild 276 + // them to reference final_record_union so that *Resolved fields return types 277 + // with all join fields available. 278 + 279 + let pass4_record_types = 280 + list.map(final_record_types, fn(record_type) { 281 + // Rebuild forward join fields using final_object_types 282 + // (which has final_record_union at "_generic_record") 283 + let new_forward_join_fields = 284 + build_forward_join_fields_with_types( 285 + record_type.meta, 286 + batch_fetcher, 287 + final_object_types, 288 + ) 289 + 290 + // Get names of forward join fields to filter out old ones 291 + let forward_join_names = 292 + list.map(record_type.meta.forward_join_fields, fn(jf) { 293 + let name = case jf { 294 + collection_meta.StrongRefField(n) -> n 295 + collection_meta.AtUriField(n) -> n 296 + } 297 + name <> "Resolved" 298 + }) 299 + 300 + // Filter out old forward join fields, keep everything else 301 + let other_fields = 302 + list.filter(record_type.fields, fn(field) { 303 + let name = schema.field_name(field) 304 + !list.contains(forward_join_names, name) 305 + }) 306 + 307 + // Combine: other fields + new forward join fields 308 + let all_fields = list.append(other_fields, new_forward_join_fields) 309 + 310 + RecordType(..record_type, fields: all_fields) 311 + }) 312 + 313 + // Rebuild object types with corrected forward joins 314 + let pass4_object_types_without_generic = 315 + list.fold(pass4_record_types, dict.new(), fn(acc, record_type) { 316 + let object_type = 317 + schema.object_type( 318 + record_type.type_name, 319 + "Record type: " <> record_type.nsid, 320 + record_type.fields, 321 + ) 322 + dict.insert(acc, record_type.nsid, object_type) 323 + }) 324 + 325 + // Rebuild Record union with truly final types 326 + let pass4_possible_types = dict.values(pass4_object_types_without_generic) 327 + let pass4_record_union = build_record_union(pass4_possible_types) 328 + let pass4_object_types = 329 + dict.insert( 330 + pass4_object_types_without_generic, 331 + "_generic_record", 332 + pass4_record_union, 333 + ) 334 + ``` 335 + 336 + **Step 2: Update the merge and return statements** 337 + 338 + Replace the existing merge (lines ~593-600) with: 339 + 340 + ```gleam 341 + // Merge ref_object_types (from lexicon defs, already has forward joins from PASS 0b) 342 + // into pass4_object_types to make them available for ref resolution 343 + let final_object_types_with_refs = 344 + dict.fold(ref_object_types, pass4_object_types, fn(acc, ref, obj_type) { 345 + dict.insert(acc, ref, obj_type) 346 + }) 347 + 348 + #(pass4_record_types, final_object_types_with_refs, field_type_registry) 349 + ``` 350 + 351 + **Step 3: Run test to verify it passes** 352 + 353 + Run: `cd lexicon_graphql && gleam test -- --filter="record_union_members_have_reverse_joins"` 354 + 355 + Expected: PASS 356 + 357 + **Step 4: Run all tests** 358 + 359 + Run: `cd lexicon_graphql && gleam test` 360 + 361 + Expected: All tests pass 362 + 363 + **Step 5: Commit** 364 + 365 + ```bash 366 + git add lexicon_graphql/test/forward_join_reverse_join_test.gleam lexicon_graphql/src/lexicon_graphql/schema/database.gleam 367 + git commit -m "fix: add PASS 4 to rebuild forward joins with final Record union 368 + 369 + Forward join *Resolved fields now return the Record union with complete 370 + types that include all reverse join and DID join fields. 371 + 372 + Previously, *Resolved fields used complete_record_union from PASS 2, 373 + which contained types without the final join fields. Now we rebuild 374 + the forward join fields in PASS 4 using final_record_union. 375 + 376 + Fixes: reverse joins not available through itemResolved queries" 377 + ``` 378 + 379 + --- 380 + 381 + ## Task 3: Integration Test with Server 382 + 383 + **Files:** 384 + - No new files - manual testing 385 + 386 + **Step 1: Build and start the server** 387 + 388 + Run: `cd server && gleam build && gleam run` 389 + 390 + **Step 2: Test the query that was failing** 391 + 392 + Execute via GraphQL playground or curl: 393 + 394 + ```graphql 395 + query { 396 + socialGrainGallery(where: {actorHandle: {eq: "chadtmiller.com"}, title: {eq: "Berlin"}}) { 397 + edges { 398 + node { 399 + title 400 + socialGrainGalleryItemViaGallery(first: 3) { 401 + edges { 402 + node { 403 + itemResolved { 404 + ... on SocialGrainPhoto { 405 + uri 406 + socialGrainPhotoExifViaPhoto(first: 1) { 407 + edges { 408 + node { 409 + exposureTime 410 + } 411 + } 412 + } 413 + } 414 + } 415 + } 416 + } 417 + } 418 + } 419 + } 420 + } 421 + } 422 + ``` 423 + 424 + Expected: `socialGrainPhotoExifViaPhoto` returns EXIF data with `exposureTime`, not null 425 + 426 + **Step 3: Verify no regressions** 427 + 428 + Test a few other queries to ensure nothing broke: 429 + - Direct photo query with reverse joins 430 + - Gallery query without forward join traversal 431 + - Other forward join fields 432 + 433 + --- 434 + 435 + ## Summary 436 + 437 + | Task | Description | Files | 438 + |------|-------------|-------| 439 + | 1 | Write failing test | `test/forward_join_reverse_join_test.gleam` | 440 + | 2 | Implement PASS 4 | `schema/database.gleam` | 441 + | 3 | Integration test | Manual testing | 442 + 443 + **Total estimated changes:** ~60 lines of new code in database.gleam, ~120 lines of test code
+7 -3
lexicon_graphql/manifest.toml
··· 3 3 4 4 packages = [ 5 5 { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 - { name = "birdie", version = "1.4.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "18599E478C14BD9EBD2465F0561F96EB9B58A24DB44AF86F103EF81D4B9834BF" }, 6 + { name = "birdie", version = "1.5.2", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "088CCB697A16E0EAC4A0B4F3F037D47E02C460594001EED45AA0F9F0559D90C5" }, 7 7 { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, 8 + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 8 9 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 - { name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" }, 10 + { name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" }, 10 11 { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 12 { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 12 13 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 13 14 { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 14 15 { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, 16 + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, 15 17 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 16 18 { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 19 + { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, 17 20 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 18 21 { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 19 22 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 20 23 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 21 - { name = "swell", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "64CFC91A6851487D07E85B02D8405F5EA06EAA74C6742915F5A78531D6237F16" }, 24 + { name = "swell", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "98177585C75E2273CA6B5D63D9D8BE072B9B0CBA6F66DA0310A44D379B89A6D1" }, 22 25 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 26 + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 23 27 { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 24 28 ] 25 29
+228
lexicon_graphql/test/forward_join_reverse_join_test.gleam
··· 1 + /// Tests that reverse join fields are available through forward join resolution 2 + /// 3 + /// This tests the fix for PASS 4 where *Resolved fields need to return 4 + /// the Record union with complete types including reverse joins. 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleam/string 9 + import gleeunit 10 + import gleeunit/should 11 + import lexicon_graphql/schema/database as db_schema_builder 12 + import lexicon_graphql/types 13 + import swell/introspection 14 + import swell/schema 15 + import swell/sdl 16 + 17 + pub fn main() { 18 + gleeunit.main() 19 + } 20 + 21 + // Helper to create a test schema with a mock fetcher 22 + fn create_test_schema_from_lexicons( 23 + lexicons: List(types.Lexicon), 24 + ) -> schema.Schema { 25 + let fetcher = fn(_collection, _params) { 26 + Ok(#([], option.None, False, False, option.None)) 27 + } 28 + 29 + case 30 + db_schema_builder.build_schema_with_fetcher( 31 + lexicons, 32 + fetcher, 33 + option.None, 34 + option.None, 35 + option.None, 36 + option.None, 37 + option.None, 38 + option.None, 39 + ) 40 + { 41 + Ok(s) -> s 42 + Error(_) -> panic as "Failed to build test schema" 43 + } 44 + } 45 + 46 + /// Test that the Record union members have reverse join fields 47 + /// 48 + /// Setup: 49 + /// - Photo collection (target of forward join) 50 + /// - Exif collection with forward join to Photo (creates reverse join on Photo) 51 + /// - GalleryItem collection with forward join to Photo (item field) 52 + /// 53 + /// Expected: When resolving itemResolved on GalleryItem, the Photo type 54 + /// should have socialGrainPhotoExifViaPhoto reverse join available 55 + pub fn record_union_members_have_reverse_joins_test() { 56 + // Photo collection - will be target of both forward and reverse joins 57 + let photo_lexicon = 58 + types.Lexicon( 59 + id: "social.grain.photo", 60 + defs: types.Defs( 61 + main: Some( 62 + types.RecordDef(type_: "record", key: None, properties: [ 63 + #( 64 + "title", 65 + types.Property( 66 + type_: "string", 67 + required: False, 68 + format: None, 69 + ref: None, 70 + refs: None, 71 + items: None, 72 + ), 73 + ), 74 + ]), 75 + ), 76 + others: dict.new(), 77 + ), 78 + ) 79 + 80 + // Exif collection - has forward join to Photo (creates reverse join) 81 + let exif_lexicon = 82 + types.Lexicon( 83 + id: "social.grain.photo.exif", 84 + defs: types.Defs( 85 + main: Some( 86 + types.RecordDef(type_: "record", key: None, properties: [ 87 + #( 88 + "photo", 89 + types.Property( 90 + type_: "string", 91 + required: True, 92 + format: Some("at-uri"), 93 + ref: None, 94 + refs: None, 95 + items: None, 96 + ), 97 + ), 98 + #( 99 + "exposureTime", 100 + types.Property( 101 + type_: "integer", 102 + required: False, 103 + format: None, 104 + ref: None, 105 + refs: None, 106 + items: None, 107 + ), 108 + ), 109 + ]), 110 + ), 111 + others: dict.new(), 112 + ), 113 + ) 114 + 115 + // GalleryItem collection - has forward join to Photo via item field 116 + let gallery_item_lexicon = 117 + types.Lexicon( 118 + id: "social.grain.gallery.item", 119 + defs: types.Defs( 120 + main: Some( 121 + types.RecordDef(type_: "record", key: None, properties: [ 122 + #( 123 + "item", 124 + types.Property( 125 + type_: "string", 126 + required: True, 127 + format: Some("at-uri"), 128 + ref: None, 129 + refs: None, 130 + items: None, 131 + ), 132 + ), 133 + ]), 134 + ), 135 + others: dict.new(), 136 + ), 137 + ) 138 + 139 + let test_schema = 140 + create_test_schema_from_lexicons([ 141 + photo_lexicon, 142 + exif_lexicon, 143 + gallery_item_lexicon, 144 + ]) 145 + 146 + // Get all types and serialize to SDL 147 + let all_types = introspection.get_all_schema_types(test_schema) 148 + let serialized = sdl.print_types(all_types) 149 + 150 + // Verify Photo type has the reverse join from Exif 151 + // This confirms the direct query works 152 + string.contains(serialized, "socialGrainPhotoExifViaPhoto") 153 + |> should.be_true 154 + 155 + // Verify GalleryItem has itemResolved field 156 + string.contains(serialized, "itemResolved: Record") 157 + |> should.be_true 158 + 159 + // Find the standalone SocialGrainPhoto type (from query field edges) 160 + let standalone_photo = 161 + list.find(all_types, fn(t) { schema.type_name(t) == "SocialGrainPhoto" }) 162 + 163 + case standalone_photo { 164 + Ok(photo) -> { 165 + let fields = schema.get_fields(photo) 166 + let field_names = list.map(fields, schema.field_name) 167 + 168 + // Standalone Photo should have the reverse join 169 + list.contains(field_names, "socialGrainPhotoExifViaPhoto") 170 + |> should.be_true 171 + } 172 + Error(_) -> should.fail() 173 + } 174 + 175 + // Now the key test: The Record union's SocialGrainPhoto member 176 + // should have the reverse join field 177 + // 178 + // Find the Record union definition and check its members have reverse joins 179 + // The SDL should show SocialGrainPhoto in the union with all its fields 180 + 181 + // Get the Record union type 182 + let record_union = 183 + list.find(all_types, fn(t) { schema.type_name(t) == "Record" }) 184 + 185 + case record_union { 186 + Ok(union_type) -> { 187 + // Get the possible types (union members) 188 + let possible_types = schema.get_possible_types(union_type) 189 + 190 + // Find the Photo type in the union 191 + let photo_type = 192 + list.find(possible_types, fn(t) { 193 + schema.type_name(t) == "SocialGrainPhoto" 194 + }) 195 + 196 + case photo_type { 197 + Ok(photo) -> { 198 + // Get the fields of the Photo type within the union 199 + let fields = schema.get_fields(photo) 200 + let field_names = list.map(fields, schema.field_name) 201 + 202 + // Count fields on each version for comparison 203 + let standalone_field_count = case standalone_photo { 204 + Ok(sp) -> list.length(schema.get_fields(sp)) 205 + Error(_) -> 0 206 + } 207 + let union_field_count = list.length(fields) 208 + 209 + // The union's Photo type should have the same number of fields 210 + // as the standalone Photo type 211 + union_field_count |> should.equal(standalone_field_count) 212 + 213 + // Check that socialGrainPhotoExifViaPhoto is present 214 + list.contains(field_names, "socialGrainPhotoExifViaPhoto") 215 + |> should.be_true 216 + } 217 + Error(_) -> { 218 + // Photo type not found in union - fail 219 + should.fail() 220 + } 221 + } 222 + } 223 + Error(_) -> { 224 + // Record union not found - fail 225 + should.fail() 226 + } 227 + } 228 + }
+3 -3
server/manifest.toml
··· 46 46 { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, 47 47 { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, 48 48 { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, 49 - { name = "pgo", version = "0.19.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "F9FCA1227368DDBE5209FE87FB28B539F87B91D36207C6AC1A05F82F50899A85" }, 49 + { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, 50 50 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 51 51 { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, 52 52 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 53 53 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 54 54 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 55 - { name = "swell", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "64CFC91A6851487D07E85B02D8405F5EA06EAA74C6742915F5A78531D6237F16" }, 55 + { name = "swell", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "98177585C75E2273CA6B5D63D9D8BE072B9B0CBA6F66DA0310A44D379B89A6D1" }, 56 56 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 57 57 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, 58 58 { name = "wisp", version = "2.1.1", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "46E2E31DECD61A3748CF6CB317D9AC432BBC8D8A6E65655A9E787BDC69389DE0" }, ··· 76 76 goose = { version = ">= 2.0.0 and < 3.0.0" } 77 77 group_registry = { version = ">= 1.0.0 and < 2.0.0" } 78 78 honk = { version = ">= 1.0.0 and < 2.0.0" } 79 - jose = { version = ">= 1.11.10 and < 2.0.0" } 79 + jose = { version = ">= 1.11.11 and < 2.0.0" } 80 80 lexicon_graphql = { path = "../lexicon_graphql" } 81 81 logging = { version = ">= 1.3.0 and < 2.0.0" } 82 82 mist = { version = ">= 5.0.3 and < 6.0.0" }