GraphQL for AT Protocol
0

Configure Feed

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

docs: add union and ref type support implementation plan

Chad Miller (Jan 16, 2026, 8:31 AM -0800) 1685dbc1 00c11adb

+993
+993
docs/plans/2026-01-16-union-ref-types.md
··· 1 + # Union and Ref Type Support Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Make lex-gql generate proper GraphQL union types and resolve ref fields to actual object types, matching the oracle schema. 6 + 7 + **Architecture:** Build a unified type registry containing all object types (main defs + others defs) before populating fields. Union fields create named GraphQL unions with `resolveType` reading `$type` from data. Ref fields resolve to actual types from the registry. 8 + 9 + **Tech Stack:** GraphQL.js (GraphQLUnionType, GraphQLObjectType), existing lex-gql infrastructure 10 + 11 + --- 12 + 13 + ## Task 1: Create Type Registry Infrastructure 14 + 15 + **Files:** 16 + - Modify: `packages/lex-gql/lex-gql.js` 17 + - Test: `packages/lex-gql/lex-gql.test.js` 18 + 19 + **Step 1: Write the failing test for type registry** 20 + 21 + Add test to verify all object types are registered: 22 + 23 + ```javascript 24 + describe('Type Registry', () => { 25 + it('creates types for main object defs (not just records)', () => { 26 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 27 + const schema = buildSchema(parsedLexicons); 28 + const sdl = printSchema(schema); 29 + 30 + // Main object types from embed lexicons 31 + expect(sdl).toContain('type AppBskyEmbedImages'); 32 + expect(sdl).toContain('type AppBskyEmbedVideo'); 33 + expect(sdl).toContain('type AppBskyEmbedExternal'); 34 + expect(sdl).toContain('type AppBskyEmbedRecord'); 35 + expect(sdl).toContain('type AppBskyEmbedRecordWithMedia'); 36 + }); 37 + 38 + it('creates types for nested object defs (others)', () => { 39 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 40 + const schema = buildSchema(parsedLexicons); 41 + const sdl = printSchema(schema); 42 + 43 + // Nested types from others defs 44 + expect(sdl).toContain('type AppBskyFeedPostReplyRef'); 45 + expect(sdl).toContain('type AppBskyEmbedImagesImage'); 46 + expect(sdl).toContain('type AppBskyEmbedDefsAspectRatio'); 47 + expect(sdl).toContain('type ComAtprotoRepoStrongRef'); 48 + }); 49 + }); 50 + ``` 51 + 52 + **Step 2: Run test to verify it fails** 53 + 54 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "creates types for main object defs"` 55 + 56 + Expected: FAIL - types like `AppBskyEmbedImages` not in schema 57 + 58 + **Step 3: Implement type registry in buildSchema** 59 + 60 + In `lex-gql.js`, refactor `buildSchema` to create all types upfront. Find the section around line 1500 and restructure: 61 + 62 + ```javascript 63 + // After creating blobType and strongRefType, before creating record types: 64 + 65 + // ============================================================================ 66 + // Phase 1: Build unified type registry (all object types) 67 + // ============================================================================ 68 + /** @type {Record<string, GraphQLObjectType>} */ 69 + const typeRegistry = {}; 70 + 71 + // First pass: create type shells for ALL object defs 72 + for (const lexicon of lexicons) { 73 + // Main defs with type: "object" (like app.bsky.embed.images) 74 + if (lexicon.defs.main?.type === 'object' && lexicon.defs.main.properties) { 75 + const typeName = nsidToTypeName(lexicon.id); 76 + typeRegistry[lexicon.id] = new GraphQLObjectType({ 77 + name: typeName, 78 + description: `Object type from ${lexicon.id}`, 79 + fields: () => ({}), // Populated later 80 + }); 81 + } 82 + 83 + // Others defs (nested types like #replyRef, #image) 84 + if (lexicon.defs.others) { 85 + for (const [defName, def] of Object.entries(lexicon.defs.others)) { 86 + if (def.type === 'object' && def.properties) { 87 + const refKey = `${lexicon.id}#${defName}`; 88 + const typeName = nsidToTypeName(lexicon.id) + defName.charAt(0).toUpperCase() + defName.slice(1); 89 + typeRegistry[refKey] = new GraphQLObjectType({ 90 + name: typeName, 91 + description: `Nested type from ${refKey}`, 92 + fields: () => ({}), // Populated later 93 + }); 94 + } 95 + } 96 + } 97 + } 98 + ``` 99 + 100 + **Step 4: Run test to verify it passes** 101 + 102 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "creates types for main object defs"` 103 + 104 + Expected: Still FAIL - types created but not included in schema 105 + 106 + **Step 5: Include type registry in schema** 107 + 108 + Find where `types` array is built (around line 1645) and add registry types: 109 + 110 + ```javascript 111 + const types = [ 112 + ...(recordUnionType ? [recordUnionType] : []), 113 + ...Object.values(typeRegistry), // Add all registered types 114 + ...Object.values(nestedTypes), 115 + ...Object.values(aggregateTypes), 116 + ...Object.values(groupByEnums), 117 + ...Object.values(fieldConditionTypes), 118 + ]; 119 + ``` 120 + 121 + **Step 6: Run tests to verify both pass** 122 + 123 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Type Registry"` 124 + 125 + Expected: PASS 126 + 127 + **Step 7: Commit** 128 + 129 + ```bash 130 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 131 + git commit -m "feat(lex-gql): add type registry for all object defs" 132 + ``` 133 + 134 + --- 135 + 136 + ## Task 2: Implement Ref Resolution Helper 137 + 138 + **Files:** 139 + - Modify: `packages/lex-gql/lex-gql.js` 140 + - Test: `packages/lex-gql/lex-gql.test.js` 141 + 142 + **Step 1: Write the failing test for ref resolution** 143 + 144 + ```javascript 145 + describe('resolveRefKey', () => { 146 + it('resolves local ref to full key', () => { 147 + expect(resolveRefKey('#replyRef', 'app.bsky.feed.post')).toBe('app.bsky.feed.post#replyRef'); 148 + }); 149 + 150 + it('resolves external ref without fragment', () => { 151 + expect(resolveRefKey('com.atproto.repo.strongRef', 'app.bsky.feed.post')).toBe('com.atproto.repo.strongRef'); 152 + }); 153 + 154 + it('resolves external ref with fragment', () => { 155 + expect(resolveRefKey('app.bsky.embed.defs#aspectRatio', 'app.bsky.embed.images')).toBe('app.bsky.embed.defs#aspectRatio'); 156 + }); 157 + }); 158 + ``` 159 + 160 + **Step 2: Run test to verify it fails** 161 + 162 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "resolveRefKey"` 163 + 164 + Expected: FAIL - `resolveRefKey` not defined 165 + 166 + **Step 3: Implement resolveRefKey** 167 + 168 + Add after the existing `parseRefUri` function: 169 + 170 + ```javascript 171 + /** 172 + * Resolve a ref string to a full registry key 173 + * @param {string} ref - The ref string (e.g., "#replyRef" or "app.bsky.embed.images") 174 + * @param {string} parentLexiconId - The lexicon ID containing this ref 175 + * @returns {string} Full registry key 176 + */ 177 + function resolveRefKey(ref, parentLexiconId) { 178 + if (ref.startsWith('#')) { 179 + // Local ref: #replyRef -> app.bsky.feed.post#replyRef 180 + return `${parentLexiconId}${ref}`; 181 + } 182 + // External ref: already fully qualified 183 + return ref; 184 + } 185 + ``` 186 + 187 + Export it in the exports section. 188 + 189 + **Step 4: Run test to verify it passes** 190 + 191 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "resolveRefKey"` 192 + 193 + Expected: PASS 194 + 195 + **Step 5: Commit** 196 + 197 + ```bash 198 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 199 + git commit -m "feat(lex-gql): add resolveRefKey helper for ref resolution" 200 + ``` 201 + 202 + --- 203 + 204 + ## Task 3: Resolve Ref Fields to Actual Types 205 + 206 + **Files:** 207 + - Modify: `packages/lex-gql/lex-gql.js` 208 + - Test: `packages/lex-gql/lex-gql.test.js` 209 + 210 + **Step 1: Write the failing test** 211 + 212 + ```javascript 213 + describe('Ref Field Resolution', () => { 214 + it('resolves ref field to actual GraphQL type', () => { 215 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 216 + const schema = buildSchema(parsedLexicons); 217 + const sdl = printSchema(schema); 218 + 219 + // reply field should be AppBskyFeedPostReplyRef, not String 220 + expect(sdl).toMatch(/reply: AppBskyFeedPostReplyRef/); 221 + }); 222 + 223 + it('resolves array of refs to list of actual types', () => { 224 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 225 + const schema = buildSchema(parsedLexicons); 226 + const sdl = printSchema(schema); 227 + 228 + // images field in AppBskyEmbedImages should be [AppBskyEmbedImagesImage!]! 229 + expect(sdl).toMatch(/images: \[AppBskyEmbedImagesImage!\]!/); 230 + }); 231 + 232 + it('resolves cross-lexicon refs', () => { 233 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 234 + const schema = buildSchema(parsedLexicons); 235 + const sdl = printSchema(schema); 236 + 237 + // aspectRatio should be AppBskyEmbedDefsAspectRatio 238 + expect(sdl).toMatch(/aspectRatio: AppBskyEmbedDefsAspectRatio/); 239 + }); 240 + }); 241 + ``` 242 + 243 + **Step 2: Run test to verify it fails** 244 + 245 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Ref Field Resolution"` 246 + 247 + Expected: FAIL - fields are String instead of actual types 248 + 249 + **Step 3: Update getGraphQLType to accept type registry** 250 + 251 + Modify the `getGraphQLType` function signature and implementation: 252 + 253 + ```javascript 254 + /** 255 + * Get GraphQL type for a property 256 + * @param {Property} prop 257 + * @param {GraphQLObjectType} [blobType] 258 + * @param {GraphQLObjectType} [strongRefType] 259 + * @param {Record<string, GraphQLObjectType>} [typeRegistry] 260 + * @param {string} [parentLexiconId] 261 + * @returns {import('graphql').GraphQLOutputType} 262 + */ 263 + function getGraphQLType(prop, blobType, strongRefType, typeRegistry, parentLexiconId) { 264 + // Handle ref type - resolve to actual type from registry 265 + if (prop.type === 'ref' && prop.ref && typeRegistry && parentLexiconId) { 266 + const refKey = resolveRefKey(prop.ref, parentLexiconId); 267 + const resolvedType = typeRegistry[refKey]; 268 + if (resolvedType) { 269 + return resolvedType; 270 + } 271 + // Fallback to String if type not found 272 + return GraphQLString; 273 + } 274 + 275 + // Handle array with ref items 276 + if (prop.type === 'array' && prop.items?.ref && typeRegistry && parentLexiconId) { 277 + const refKey = resolveRefKey(prop.items.ref, parentLexiconId); 278 + const itemType = typeRegistry[refKey] || GraphQLString; 279 + return new GraphQLList(new GraphQLNonNull(itemType)); 280 + } 281 + 282 + // Existing type mapping 283 + /** @type {Record<string, import('graphql').GraphQLOutputType>} */ 284 + const typeMap = { 285 + string: GraphQLString, 286 + integer: GraphQLInt, 287 + boolean: GraphQLBoolean, 288 + number: GraphQLFloat, 289 + blob: blobType || GraphQLString, 290 + bytes: GraphQLString, 291 + 'cid-link': GraphQLString, 292 + union: GraphQLString, // Will be handled separately for union types 293 + }; 294 + 295 + if (prop.type === 'array' && prop.items) { 296 + const itemType = typeMap[prop.items.type] || GraphQLString; 297 + return new GraphQLList(new GraphQLNonNull(itemType)); 298 + } 299 + 300 + return typeMap[prop.type] || GraphQLString; 301 + } 302 + ``` 303 + 304 + **Step 4: Update all getGraphQLType call sites** 305 + 306 + Search for all calls to `getGraphQLType` and add the new parameters. Key locations: 307 + - `createRecordType` - pass `typeRegistry` and `lexicon.id` 308 + - `createNestedObjectType` - pass `typeRegistry` and `lexiconId` 309 + 310 + **Step 5: Populate type registry fields in second pass** 311 + 312 + After creating all type shells, populate their fields: 313 + 314 + ```javascript 315 + // Phase 2: Populate fields for all registered types 316 + for (const lexicon of lexicons) { 317 + // Populate main object type fields 318 + if (lexicon.defs.main?.type === 'object' && typeRegistry[lexicon.id]) { 319 + const type = typeRegistry[lexicon.id]; 320 + // Use Object.defineProperty to update the fields thunk 321 + const fields = buildObjectFields(lexicon.defs.main, typeRegistry, lexicon.id, blobType); 322 + Object.defineProperty(type, '_fields', { value: fields, writable: true }); 323 + } 324 + 325 + // Populate nested type fields 326 + if (lexicon.defs.others) { 327 + for (const [defName, def] of Object.entries(lexicon.defs.others)) { 328 + const refKey = `${lexicon.id}#${defName}`; 329 + if (def.type === 'object' && typeRegistry[refKey]) { 330 + const type = typeRegistry[refKey]; 331 + const fields = buildObjectFields(def, typeRegistry, lexicon.id, blobType); 332 + Object.defineProperty(type, '_fields', { value: fields, writable: true }); 333 + } 334 + } 335 + } 336 + } 337 + ``` 338 + 339 + **Step 6: Create buildObjectFields helper** 340 + 341 + ```javascript 342 + /** 343 + * Build fields object for a GraphQL type from lexicon definition 344 + * @param {RecordDef} def 345 + * @param {Record<string, GraphQLObjectType>} typeRegistry 346 + * @param {string} parentLexiconId 347 + * @param {GraphQLObjectType} blobType 348 + * @returns {Record<string, import('graphql').GraphQLFieldConfig<*, *>>} 349 + */ 350 + function buildObjectFields(def, typeRegistry, parentLexiconId, blobType) { 351 + /** @type {Record<string, import('graphql').GraphQLFieldConfig<*, *>>} */ 352 + const fields = {}; 353 + 354 + for (const prop of def.properties || []) { 355 + const graphqlType = getGraphQLType(prop, blobType, undefined, typeRegistry, parentLexiconId); 356 + fields[prop.name] = { 357 + type: prop.required ? new GraphQLNonNull(graphqlType) : graphqlType, 358 + description: 'Field from object definition', 359 + }; 360 + } 361 + 362 + return fields; 363 + } 364 + ``` 365 + 366 + **Step 7: Run tests** 367 + 368 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Ref Field Resolution"` 369 + 370 + Expected: PASS 371 + 372 + **Step 8: Commit** 373 + 374 + ```bash 375 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 376 + git commit -m "feat(lex-gql): resolve ref fields to actual GraphQL types" 377 + ``` 378 + 379 + --- 380 + 381 + ## Task 4: Create Union Types for Union Fields 382 + 383 + **Files:** 384 + - Modify: `packages/lex-gql/lex-gql.js` 385 + - Test: `packages/lex-gql/lex-gql.test.js` 386 + 387 + **Step 1: Write the failing test** 388 + 389 + ```javascript 390 + describe('Union Types', () => { 391 + it('creates named union type for union field', () => { 392 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 393 + const schema = buildSchema(parsedLexicons); 394 + const sdl = printSchema(schema); 395 + 396 + // Should have union type for embed field 397 + expect(sdl).toContain('union AppBskyFeedPostEmbed ='); 398 + expect(sdl).toContain('AppBskyEmbedImages'); 399 + expect(sdl).toContain('AppBskyEmbedVideo'); 400 + }); 401 + 402 + it('uses union type for field instead of String', () => { 403 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 404 + const schema = buildSchema(parsedLexicons); 405 + const sdl = printSchema(schema); 406 + 407 + // embed field should use the union type 408 + expect(sdl).toMatch(/embed: AppBskyFeedPostEmbed/); 409 + }); 410 + 411 + it('creates union for labels field', () => { 412 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 413 + const schema = buildSchema(parsedLexicons); 414 + const sdl = printSchema(schema); 415 + 416 + expect(sdl).toContain('union AppBskyFeedPostLabels ='); 417 + }); 418 + }); 419 + ``` 420 + 421 + **Step 2: Run test to verify it fails** 422 + 423 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Union Types"` 424 + 425 + Expected: FAIL - no union types created 426 + 427 + **Step 3: Implement union type creation** 428 + 429 + Add after type registry creation: 430 + 431 + ```javascript 432 + // ============================================================================ 433 + // Phase 2: Create union types for union fields 434 + // ============================================================================ 435 + /** @type {Record<string, GraphQLUnionType>} */ 436 + const unionTypes = {}; 437 + 438 + /** 439 + * Convert field name to PascalCase 440 + * @param {string} name 441 + * @returns {string} 442 + */ 443 + function toPascalCase(name) { 444 + return name.charAt(0).toUpperCase() + name.slice(1); 445 + } 446 + 447 + // Scan all lexicons for union fields 448 + for (const lexicon of lexicons) { 449 + const defs = [ 450 + { def: lexicon.defs.main, prefix: '' }, 451 + ...Object.entries(lexicon.defs.others || {}).map(([name, def]) => ({ 452 + def, 453 + prefix: toPascalCase(name) 454 + })), 455 + ]; 456 + 457 + for (const { def, prefix } of defs) { 458 + if (!def?.properties) continue; 459 + 460 + const parentTypeName = nsidToTypeName(lexicon.id) + prefix; 461 + 462 + for (const prop of def.properties) { 463 + if (prop.type === 'union' && prop.refs && prop.refs.length > 0) { 464 + const unionName = `${parentTypeName}${toPascalCase(prop.name)}`; 465 + 466 + // Resolve all refs to their types 467 + const memberTypes = prop.refs 468 + .map(ref => { 469 + const refKey = resolveRefKey(ref, lexicon.id); 470 + return typeRegistry[refKey]; 471 + }) 472 + .filter(Boolean); 473 + 474 + if (memberTypes.length > 0) { 475 + unionTypes[unionName] = new GraphQLUnionType({ 476 + name: unionName, 477 + description: `Union type for ${prop.name} field`, 478 + types: memberTypes, 479 + resolveType: (value) => { 480 + const typeId = value?.$type || value?.['$type']; 481 + if (!typeId) return null; 482 + return typeRegistry[typeId] || null; 483 + }, 484 + }); 485 + } 486 + } 487 + } 488 + } 489 + } 490 + ``` 491 + 492 + **Step 4: Update getGraphQLType to use union types** 493 + 494 + Add handling for union fields: 495 + 496 + ```javascript 497 + // In getGraphQLType, add before the typeMap: 498 + if (prop.type === 'union' && prop.refs && unionTypes) { 499 + const unionName = `${parentTypeName}${toPascalCase(prop.name)}`; 500 + const unionType = unionTypes[unionName]; 501 + if (unionType) { 502 + return unionType; 503 + } 504 + } 505 + ``` 506 + 507 + **Step 5: Include union types in schema** 508 + 509 + Update the types array: 510 + 511 + ```javascript 512 + const types = [ 513 + ...(recordUnionType ? [recordUnionType] : []), 514 + ...Object.values(typeRegistry), 515 + ...Object.values(unionTypes), // Add union types 516 + ...Object.values(aggregateTypes), 517 + // ... 518 + ]; 519 + ``` 520 + 521 + **Step 6: Run tests** 522 + 523 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Union Types"` 524 + 525 + Expected: PASS 526 + 527 + **Step 7: Commit** 528 + 529 + ```bash 530 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 531 + git commit -m "feat(lex-gql): create GraphQL union types for union fields" 532 + ``` 533 + 534 + --- 535 + 536 + ## Task 5: Add Forward Joins to Nested Types 537 + 538 + **Files:** 539 + - Modify: `packages/lex-gql/lex-gql.js` 540 + - Test: `packages/lex-gql/lex-gql.test.js` 541 + 542 + **Step 1: Write the failing test** 543 + 544 + ```javascript 545 + describe('Forward Joins on Nested Types', () => { 546 + it('adds forward join fields to nested types with strongRef', () => { 547 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 548 + const schema = buildSchema(parsedLexicons); 549 + const sdl = printSchema(schema); 550 + 551 + // AppBskyFeedPostReplyRef should have parentResolved and rootResolved 552 + expect(sdl).toMatch(/type AppBskyFeedPostReplyRef \{[\s\S]*?parentResolved: Record/); 553 + expect(sdl).toMatch(/type AppBskyFeedPostReplyRef \{[\s\S]*?rootResolved: Record/); 554 + }); 555 + 556 + it('adds forward join to ComAtprotoRepoStrongRef', () => { 557 + const parsedLexicons = realLexicons.map((l) => parseLexicon(l.content)); 558 + const schema = buildSchema(parsedLexicons); 559 + const sdl = printSchema(schema); 560 + 561 + // ComAtprotoRepoStrongRef should have uriResolved 562 + expect(sdl).toMatch(/type ComAtprotoRepoStrongRef \{[\s\S]*?uriResolved: Record/); 563 + }); 564 + }); 565 + ``` 566 + 567 + **Step 2: Run test to verify it fails** 568 + 569 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Forward Joins on Nested Types"` 570 + 571 + Expected: FAIL - no forward join fields on nested types 572 + 573 + **Step 3: Extract forward join logic into shared function** 574 + 575 + ```javascript 576 + /** 577 + * Add forward join fields for strongRef references 578 + * @param {Record<string, import('graphql').GraphQLFieldConfig<*, *>>} fields 579 + * @param {Property[]} properties 580 + * @param {GraphQLUnionType} recordUnionType 581 + */ 582 + function addForwardJoinFields(fields, properties, recordUnionType) { 583 + if (!recordUnionType) return; 584 + 585 + for (const prop of properties) { 586 + if (isForwardJoinField(prop)) { 587 + fields[`${prop.name}Resolved`] = { 588 + type: recordUnionType, 589 + description: 'Forward join to referenced record', 590 + }; 591 + } 592 + } 593 + } 594 + ``` 595 + 596 + **Step 4: Use shared function in buildObjectFields** 597 + 598 + Update `buildObjectFields` to accept and use `recordUnionType`: 599 + 600 + ```javascript 601 + function buildObjectFields(def, typeRegistry, parentLexiconId, blobType, recordUnionType) { 602 + /** @type {Record<string, import('graphql').GraphQLFieldConfig<*, *>>} */ 603 + const fields = {}; 604 + 605 + for (const prop of def.properties || []) { 606 + const graphqlType = getGraphQLType(prop, blobType, undefined, typeRegistry, parentLexiconId); 607 + fields[prop.name] = { 608 + type: prop.required ? new GraphQLNonNull(graphqlType) : graphqlType, 609 + description: 'Field from object definition', 610 + }; 611 + } 612 + 613 + // Add forward join fields 614 + addForwardJoinFields(fields, def.properties || [], recordUnionType); 615 + 616 + return fields; 617 + } 618 + ``` 619 + 620 + **Step 5: Run tests** 621 + 622 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Forward Joins on Nested Types"` 623 + 624 + Expected: PASS 625 + 626 + **Step 6: Commit** 627 + 628 + ```bash 629 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 630 + git commit -m "feat(lex-gql): add forward joins to nested types" 631 + ``` 632 + 633 + --- 634 + 635 + ## Task 6: Add Data Hydration for Union Types 636 + 637 + **Files:** 638 + - Modify: `packages/lex-gql/lex-gql.js` 639 + - Test: `packages/lex-gql/lex-gql.test.js` 640 + 641 + **Step 1: Write the failing test** 642 + 643 + ```javascript 644 + describe('Union Type Resolution at Runtime', () => { 645 + it('resolves correct type from $type field', async () => { 646 + const lexicons = [ 647 + parseLexicon({ 648 + lexicon: 1, 649 + id: 'test.post', 650 + defs: { 651 + main: { 652 + type: 'record', 653 + key: 'tid', 654 + record: { 655 + type: 'object', 656 + properties: { 657 + text: { type: 'string' }, 658 + embed: { 659 + type: 'union', 660 + refs: ['test.embed.images', 'test.embed.video'], 661 + }, 662 + }, 663 + }, 664 + }, 665 + }, 666 + }), 667 + parseLexicon({ 668 + lexicon: 1, 669 + id: 'test.embed.images', 670 + defs: { 671 + main: { 672 + type: 'object', 673 + properties: { 674 + images: { type: 'array', items: { type: 'string' } }, 675 + }, 676 + }, 677 + }, 678 + }), 679 + parseLexicon({ 680 + lexicon: 1, 681 + id: 'test.embed.video', 682 + defs: { 683 + main: { 684 + type: 'object', 685 + properties: { 686 + url: { type: 'string' }, 687 + }, 688 + }, 689 + }, 690 + }), 691 + ]; 692 + 693 + const mockQuery = async (op) => { 694 + if (op.type === 'findMany') { 695 + return { 696 + records: [ 697 + { 698 + uri: 'at://did:test/test.post/1', 699 + did: 'did:test', 700 + collection: 'test.post', 701 + cid: 'bafytest', 702 + indexedAt: '2024-01-01T00:00:00Z', 703 + record: { 704 + text: 'Hello', 705 + embed: { 706 + $type: 'test.embed.images', 707 + images: ['img1.jpg', 'img2.jpg'], 708 + }, 709 + }, 710 + }, 711 + ], 712 + cursor: null, 713 + }; 714 + } 715 + return { count: 0 }; 716 + }; 717 + 718 + const adapter = createAdapter(lexicons, { query: mockQuery }); 719 + const result = await adapter.execute(` 720 + query { 721 + testPost(first: 10) { 722 + edges { 723 + node { 724 + text 725 + embed { 726 + __typename 727 + ... on TestEmbedImages { 728 + images 729 + } 730 + } 731 + } 732 + } 733 + } 734 + } 735 + `); 736 + 737 + expect(result.errors).toBeUndefined(); 738 + expect(result.data.testPost.edges[0].node.embed.__typename).toBe('TestEmbedImages'); 739 + expect(result.data.testPost.edges[0].node.embed.images).toEqual(['img1.jpg', 'img2.jpg']); 740 + }); 741 + 742 + it('returns null for missing $type', async () => { 743 + // Similar test but with embed missing $type 744 + // ... (abbreviated for plan) 745 + }); 746 + }); 747 + ``` 748 + 749 + **Step 2: Run test to verify it fails** 750 + 751 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Union Type Resolution at Runtime"` 752 + 753 + Expected: FAIL - union type resolution not working 754 + 755 + **Step 3: Update hydrateRecord to preserve nested objects** 756 + 757 + The `hydrateRecord` function needs to pass through union field data without flattening: 758 + 759 + ```javascript 760 + // In hydrateRecord, when processing a union field: 761 + if (prop.type === 'union') { 762 + // Pass through the entire object with $type intact 763 + result[prop.name] = record[prop.name]; 764 + } 765 + ``` 766 + 767 + **Step 4: Run tests** 768 + 769 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "Union Type Resolution at Runtime"` 770 + 771 + Expected: PASS 772 + 773 + **Step 5: Commit** 774 + 775 + ```bash 776 + git add packages/lex-gql/lex-gql.js packages/lex-gql/lex-gql.test.js 777 + git commit -m "feat(lex-gql): add runtime union type resolution" 778 + ``` 779 + 780 + --- 781 + 782 + ## Task 7: Update Existing Tests for New Behavior 783 + 784 + **Files:** 785 + - Modify: `packages/lex-gql/lex-gql.test.js` 786 + 787 + **Step 1: Update mapLexiconType tests** 788 + 789 + The test "maps ref to String (URI)" is now incorrect since refs resolve to actual types. Update: 790 + 791 + ```javascript 792 + it('maps ref to String when no registry available', () => { 793 + // mapLexiconType without registry still returns String 794 + expect(mapLexiconType('ref')).toBe('String'); 795 + }); 796 + 797 + it('maps union to String when no registry available', () => { 798 + expect(mapLexiconType('union')).toBe('String'); 799 + }); 800 + ``` 801 + 802 + **Step 2: Run full test suite** 803 + 804 + Run: `cd packages/lex-gql && pnpm test` 805 + 806 + Expected: All tests pass 807 + 808 + **Step 3: Commit** 809 + 810 + ```bash 811 + git add packages/lex-gql/lex-gql.test.js 812 + git commit -m "test(lex-gql): update tests for ref/union type resolution" 813 + ``` 814 + 815 + --- 816 + 817 + ## Task 8: Verify Schema Comparison Improvement 818 + 819 + **Files:** 820 + - Test: `packages/lex-gql/lex-gql.test.js` 821 + 822 + **Step 1: Run schema comparison test** 823 + 824 + Run: `cd packages/lex-gql && pnpm test -- --reporter=verbose -t "compares generated schema against oracle"` 825 + 826 + **Step 2: Check improvement in match percentage** 827 + 828 + The output should show significantly improved match percentage (from ~63% to higher). 829 + 830 + Expected improvements: 831 + - Types like `AppBskyEmbedImages`, `AppBskyEmbedVideo` now exist 832 + - Union types like `AppBskyFeedPostEmbed` now exist 833 + - Nested types like `AppBskyFeedPostReplyRef` now have proper fields 834 + 835 + **Step 3: Document any remaining gaps** 836 + 837 + If there are still gaps, note them for future work. Common remaining gaps might include: 838 + - Input types for unions 839 + - Specific filtering/sorting for union fields 840 + 841 + **Step 4: Commit any test updates** 842 + 843 + ```bash 844 + git add packages/lex-gql/lex-gql.test.js 845 + git commit -m "test(lex-gql): verify schema comparison improvement" 846 + ``` 847 + 848 + --- 849 + 850 + ## Task 9: Update E2E Tests 851 + 852 + **Files:** 853 + - Modify: `e2e/lex-gql.e2e.test.js` 854 + 855 + **Step 1: Update embed field tests to use union syntax** 856 + 857 + Now that unions work, update the e2e tests to query union fields properly: 858 + 859 + ```javascript 860 + it('queries posts with embedded images', async () => { 861 + insertRecord.run( 862 + 'at://did:plc:alice/app.bsky.feed.post/withimg', 863 + 'did:plc:alice', 864 + 'app.bsky.feed.post', 865 + 'withimg', 866 + 'bafyimg', 867 + JSON.stringify({ 868 + text: 'Check out this photo!', 869 + createdAt: '2024-01-15T11:00:00.000Z', 870 + embed: { 871 + $type: 'app.bsky.embed.images', 872 + images: [ 873 + { 874 + alt: 'A beautiful sunset', 875 + image: { 876 + $type: 'blob', 877 + ref: { $link: 'bafyimage123' }, 878 + mimeType: 'image/jpeg', 879 + size: 245000, 880 + }, 881 + }, 882 + ], 883 + }, 884 + }), 885 + '2024-01-15T11:00:00.000Z' 886 + ); 887 + 888 + const result = await adapter.execute(` 889 + query { 890 + appBskyFeedPost(first: 10) { 891 + edges { 892 + node { 893 + text 894 + embed { 895 + __typename 896 + ... on AppBskyEmbedImages { 897 + images { 898 + alt 899 + image { 900 + cid 901 + mimeType 902 + } 903 + } 904 + } 905 + } 906 + } 907 + } 908 + } 909 + } 910 + `); 911 + 912 + expect(result.errors).toBeUndefined(); 913 + const post = result.data.appBskyFeedPost.edges[0].node; 914 + expect(post.text).toBe('Check out this photo!'); 915 + expect(post.embed.__typename).toBe('AppBskyEmbedImages'); 916 + expect(post.embed.images[0].alt).toBe('A beautiful sunset'); 917 + }); 918 + ``` 919 + 920 + **Step 2: Update reply field tests to use object syntax** 921 + 922 + ```javascript 923 + it('queries posts with reply reference', async () => { 924 + // ... insert records ... 925 + 926 + const result = await adapter.execute(` 927 + query { 928 + appBskyFeedPost(first: 10, where: { did: { eq: "did:plc:alice" } }) { 929 + edges { 930 + node { 931 + text 932 + reply { 933 + root { 934 + uri 935 + cid 936 + } 937 + parent { 938 + uri 939 + cid 940 + } 941 + } 942 + } 943 + } 944 + } 945 + } 946 + `); 947 + 948 + expect(result.errors).toBeUndefined(); 949 + const post = result.data.appBskyFeedPost.edges[0].node; 950 + expect(post.reply.root.uri).toBe('at://did:plc:bob/app.bsky.feed.post/parent'); 951 + }); 952 + ``` 953 + 954 + **Step 3: Run e2e tests** 955 + 956 + Run: `cd e2e && pnpm test` 957 + 958 + Expected: PASS 959 + 960 + **Step 4: Commit** 961 + 962 + ```bash 963 + git add e2e/lex-gql.e2e.test.js 964 + git commit -m "test(e2e): update tests to use union and ref types" 965 + ``` 966 + 967 + --- 968 + 969 + ## Task 10: Final Verification 970 + 971 + **Step 1: Run all tests across packages** 972 + 973 + ```bash 974 + pnpm test 975 + ``` 976 + 977 + Expected: All tests pass in lex-gql, lex-gql-sqlite, and e2e 978 + 979 + **Step 2: Run typecheck** 980 + 981 + ```bash 982 + cd packages/lex-gql && pnpm typecheck 983 + cd packages/lex-gql-sqlite && pnpm typecheck 984 + ``` 985 + 986 + Expected: No type errors 987 + 988 + **Step 3: Final commit for any cleanup** 989 + 990 + ```bash 991 + git add -A 992 + git commit -m "chore: final cleanup for union/ref type support" 993 + ```