@workos/oagen-emitters 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
@@ -1,10 +1,17 @@
1
1
  import type { Model, Field, EmitterContext, TypeRef, UnionType, PrimitiveType } from '@workos/oagen';
2
2
  import { mapTypeRef as tsMapTypeRef } from './type-map.js';
3
3
  import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
4
- import { relativeImport, buildKnownTypeNames, isBaselineGeneric, createServiceDirResolver } from './utils.js';
4
+ import {
5
+ relativeImport,
6
+ buildKnownTypeNames,
7
+ isBaselineGeneric,
8
+ createServiceDirResolver,
9
+ modelHasNewFields,
10
+ } from './utils.js';
11
+ import { liveSurfaceHasFunction, liveSurfaceHasFile, liveSurfaceFunctionPath } from './live-surface.js';
5
12
 
6
13
  // ---------------------------------------------------------------------------
7
- // Guard strategy — determines how a field assignment is wrapped
14
+ // Guard strategy
8
15
  // ---------------------------------------------------------------------------
9
16
 
10
17
  type GuardStrategy =
@@ -13,10 +20,6 @@ type GuardStrategy =
13
20
  | { kind: 'coalesce'; fallback: string }
14
21
  | { kind: 'non-null-assert' };
15
22
 
16
- // ---------------------------------------------------------------------------
17
- // Baseline types used by planning functions
18
- // ---------------------------------------------------------------------------
19
-
20
23
  interface BaselineFieldInfo {
21
24
  type: string;
22
25
  optional: boolean;
@@ -32,9 +35,25 @@ interface BaselineInterface {
32
35
  // ---------------------------------------------------------------------------
33
36
 
34
37
  /**
35
- * Build a deserialization expression for a type reference.
36
- * @param nullFallback - fallback value for nullable inner expressions (default 'null')
38
+ * Decide whether a `deserialize${X}` / `serialize${X}` helper will be
39
+ * resolvable at compile time. A helper is callable when:
40
+ * - the live SDK already exports it (live-surface knows), OR
41
+ * - the emitter is producing the dep model's serializer this run, which
42
+ * happens when `modelHasNewFields(dep, ctx)` says the dep needs
43
+ * regeneration.
44
+ *
45
+ * When neither condition holds, expression builders fall back to passing
46
+ * the value through unchanged — `deserializeX(wire)` and `serializeX(model)`
47
+ * become `wire` / `model` respectively. Safe because elided cases imply
48
+ * the wire and domain shapes are identical (no IR additions).
37
49
  */
50
+ function helperExists(helperName: string, depModelName: string, ctx: EmitterContext): boolean {
51
+ if (liveSurfaceHasFunction(helperName)) return true;
52
+ const depModel = ctx.spec.models.find((m) => m.name === depModelName);
53
+ if (!depModel) return false;
54
+ return modelHasNewFields(depModel, ctx);
55
+ }
56
+
38
57
  export function deserializeExpression(
39
58
  ref: TypeRef,
40
59
  wireExpr: string,
@@ -49,11 +68,17 @@ export function deserializeExpression(
49
68
  return wireExpr;
50
69
  case 'model': {
51
70
  const name = resolveInterfaceName(ref.name, ctx);
71
+ // The deserialize helper may not exist if its serializer file was
72
+ // elided (no baseline serializer + no new fields ⇒ no generation).
73
+ // Fall back to passing the wire value through — the runtime shape
74
+ // is identical to the domain shape in those cases.
75
+ if (!helperExists(`deserialize${name}`, ref.name, ctx)) return wireExpr;
52
76
  return `deserialize${name}(${wireExpr})`;
53
77
  }
54
78
  case 'array':
55
79
  if (ref.items.kind === 'model') {
56
80
  const name = resolveInterfaceName(ref.items.name, ctx);
81
+ if (!helperExists(`deserialize${name}`, ref.items.name, ctx)) return wireExpr;
57
82
  return `${wireExpr}.map(deserialize${name})`;
58
83
  }
59
84
  return wireExpr;
@@ -83,10 +108,6 @@ export function deserializeExpression(
83
108
  }
84
109
  }
85
110
 
86
- /**
87
- * Build a serialization expression for a type reference.
88
- * @param nullFallback - fallback value for nullable inner expressions (default 'null')
89
- */
90
111
  export function serializeExpression(
91
112
  ref: TypeRef,
92
113
  domainExpr: string,
@@ -101,11 +122,13 @@ export function serializeExpression(
101
122
  return domainExpr;
102
123
  case 'model': {
103
124
  const name = resolveInterfaceName(ref.name, ctx);
125
+ if (!helperExists(`serialize${name}`, ref.name, ctx)) return domainExpr;
104
126
  return `serialize${name}(${domainExpr})`;
105
127
  }
106
128
  case 'array':
107
129
  if (ref.items.kind === 'model') {
108
130
  const name = resolveInterfaceName(ref.items.name, ctx);
131
+ if (!helperExists(`serialize${name}`, ref.items.name, ctx)) return domainExpr;
109
132
  return `${domainExpr}.map(serialize${name})`;
110
133
  }
111
134
  return domainExpr;
@@ -155,7 +178,6 @@ function serializePrimitive(ref: PrimitiveType, domainExpr: string): string {
155
178
  // Union helpers
156
179
  // ---------------------------------------------------------------------------
157
180
 
158
- /** Extract unique model names from a union's variants. */
159
181
  export function uniqueModelVariants(ref: UnionType): string[] {
160
182
  const modelNames = new Set<string>();
161
183
  for (const v of ref.variants) {
@@ -177,6 +199,10 @@ function renderDiscriminatorSwitch(
177
199
  const fn = `${direction}${resolved}`;
178
200
  cases.push(`case '${value}': return ${fn}(${expr} as any)`);
179
201
  }
202
+ // No mapping → passthrough. Without this guard, an empty `disc.mapping`
203
+ // emits `switch { ; default: ... }` which is invalid TypeScript syntax
204
+ // (the leading `;` looks like a stray statement before the first case).
205
+ if (cases.length === 0) return expr;
180
206
  return `(() => { switch ((${expr} as any).${disc.property}) { ${cases.join('; ')}; default: return ${expr} } })()`;
181
207
  }
182
208
 
@@ -199,7 +225,6 @@ function renderAllOfMerge(
199
225
  // Type inspection helpers
200
226
  // ---------------------------------------------------------------------------
201
227
 
202
- /** Check whether a TypeRef involves a function call in serialization. */
203
228
  export function needsNullGuard(ref: TypeRef): boolean {
204
229
  switch (ref.kind) {
205
230
  case 'model':
@@ -241,11 +266,6 @@ export function hasDateTimeConversion(ref: TypeRef): boolean {
241
266
  }
242
267
  }
243
268
 
244
- /**
245
- * Collect model names that will actually be called in serialize/deserialize expressions.
246
- * Unlike collectModelRefs (which walks all union variants), this only includes models
247
- * that the expression functions will actually invoke a serializer/deserializer for.
248
- */
249
269
  export function collectSerializedModelRefs(ref: TypeRef): string[] {
250
270
  switch (ref.kind) {
251
271
  case 'model':
@@ -270,7 +290,6 @@ export function collectSerializedModelRefs(ref: TypeRef): string[] {
270
290
  }
271
291
  }
272
292
 
273
- /** Return a TypeScript default value expression for a type. */
274
293
  export function defaultForType(ref: TypeRef): string | null {
275
294
  switch (ref.kind) {
276
295
  case 'literal':
@@ -414,10 +433,9 @@ export function serializerHasBaselineIncompatibility(
414
433
  }
415
434
 
416
435
  // ---------------------------------------------------------------------------
417
- // Field assignment planning — replaces inline if/else chains
436
+ // Field assignment planning
418
437
  // ---------------------------------------------------------------------------
419
438
 
420
- /** Plan a single deserializer field assignment. */
421
439
  export function planDeserializeField(
422
440
  field: Field,
423
441
  baselineDomain: BaselineInterface | undefined,
@@ -429,8 +447,40 @@ export function planDeserializeField(
429
447
  const wire = wireFieldName(field.name);
430
448
  const wireAccess = `response.${wire}`;
431
449
  const skip = skipFormatFields.has(field.name);
432
- const fallbackForNullable = field.type.kind === 'nullable' ? 'null' : 'undefined';
433
- const expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx, fallbackForNullable);
450
+
451
+ // Fallback selection considers both the IR field type and the baseline
452
+ // domain field. When baseline declares the field as `optional`
453
+ // (undefined-permitting) but the IR is nullable (null-only), prefer
454
+ // `undefined` so the deserialize output matches the baseline interface
455
+ // signature. Otherwise the assignment becomes
456
+ // `Record<...> | null → Record<...> | undefined` (TS2322).
457
+ const baselineDomainField = baselineDomain?.fields?.[domain];
458
+ const baselineDomainAcceptsNull = baselineDomainField?.type?.includes('null') ?? false;
459
+ let fallbackForNullable: string;
460
+ if (field.type.kind === 'nullable') {
461
+ fallbackForNullable =
462
+ baselineDomainField && baselineDomainField.optional && !baselineDomainAcceptsNull ? 'undefined' : 'null';
463
+ } else {
464
+ fallbackForNullable = 'undefined';
465
+ }
466
+ let expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx, fallbackForNullable);
467
+
468
+ // Baseline-declared Date for an IR `string` field: the interface body
469
+ // uses the baseline's `Date` (line 392 in models.ts), so the serializer
470
+ // must convert with `new Date(...)`. The IR type doesn't carry the
471
+ // `format: date-time` here (the spec just said `type: string`), so
472
+ // `deserializeExpression` would otherwise return the raw wire access.
473
+ const baselineField = baselineDomain?.fields?.[domain];
474
+ if (
475
+ !skip &&
476
+ expr === wireAccess &&
477
+ baselineField?.type === 'Date' &&
478
+ field.type.kind === 'primitive' &&
479
+ field.type.type === 'string'
480
+ ) {
481
+ expr = `new Date(${wireAccess})`;
482
+ }
483
+
434
484
  const isNewField = baselineDomain && !baselineDomain.fields?.[domain];
435
485
  const effectivelyOptional = !field.required || isNewField;
436
486
 
@@ -446,13 +496,11 @@ function planDeserializeGuard(
446
496
  isNewField: boolean | null | undefined,
447
497
  baselineResponse: BaselineInterface | undefined,
448
498
  ): GuardStrategy {
449
- // Optional field with function-call expression → null check
450
499
  if (effectivelyOptional && expr !== wireAccess && needsNullGuard(field.type)) {
451
500
  const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
452
501
  return { kind: 'null-check', fallback };
453
502
  }
454
503
 
455
- // Required field with direct assignment — may need coalesce fallback
456
504
  if (field.required && expr === wireAccess) {
457
505
  const wire = wireFieldName(field.name);
458
506
  const responseFieldInfo = baselineResponse?.fields?.[wire];
@@ -467,7 +515,6 @@ function planDeserializeGuard(
467
515
  return { kind: 'direct' };
468
516
  }
469
517
 
470
- /** Plan a single serializer field assignment. */
471
518
  export function planSerializeField(
472
519
  field: Field,
473
520
  baselineDomain: BaselineInterface | undefined,
@@ -479,8 +526,37 @@ export function planSerializeField(
479
526
  const domain = fieldName(field.name);
480
527
  const domainAccess = `model.${domain}`;
481
528
  const skip = skipFormatFields.has(field.name);
482
- const fallbackForNullable = field.type.kind === 'nullable' ? 'null' : 'undefined';
483
- const expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx, fallbackForNullable);
529
+
530
+ // Symmetric to `planDeserializeField`: when the baseline wire is
531
+ // `optional` (undefined) but the IR is nullable, fall back to
532
+ // `undefined` so the serialized output matches the baseline wire shape.
533
+ const baselineWireField = baselineResponse?.fields?.[wire];
534
+ const baselineWireAcceptsNull = baselineWireField?.type?.includes('null') ?? false;
535
+ let fallbackForNullable: string;
536
+ if (field.type.kind === 'nullable') {
537
+ fallbackForNullable =
538
+ baselineWireField && baselineWireField.optional && !baselineWireAcceptsNull ? 'undefined' : 'null';
539
+ } else {
540
+ fallbackForNullable = 'undefined';
541
+ }
542
+ let expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx, fallbackForNullable);
543
+
544
+ // Symmetric to `planDeserializeField`: when the baseline declares the
545
+ // domain field as `Date` but the IR carries a plain `string`, the
546
+ // serializer must call `.toISOString()` so the wire form gets a string
547
+ // back. Without this, the serializer assigns a `Date` model field into
548
+ // a `string` wire field — TS2322.
549
+ const baselineField = baselineDomain?.fields?.[domain];
550
+ if (
551
+ !skip &&
552
+ expr === domainAccess &&
553
+ baselineField?.type === 'Date' &&
554
+ field.type.kind === 'primitive' &&
555
+ field.type.type === 'string'
556
+ ) {
557
+ expr = field.required ? `${domainAccess}.toISOString()` : `${domainAccess}?.toISOString()`;
558
+ }
559
+
484
560
  const isNewSerField = baselineDomain && !baselineDomain.fields?.[domain];
485
561
  const effectivelyOptionalSer = !field.required || isNewSerField;
486
562
 
@@ -508,14 +584,24 @@ function planSerializeGuard(
508
584
  const wire = wireFieldName(field.name);
509
585
  const domain = fieldName(field.name);
510
586
 
511
- // Function-call expression for optional/nullable fields → null check
512
587
  const shouldGuardSer = effectivelyOptionalSer || field.type.kind === 'nullable';
513
588
  if (expr !== domainAccess && needsNullGuard(field.type) && shouldGuardSer) {
514
- const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
589
+ let fallback: string = field.type.kind === 'nullable' ? 'null' : 'undefined';
590
+ // If the wire side is required but the field guard would otherwise emit
591
+ // `undefined`, the assignment becomes `string | undefined → string`.
592
+ // Pick a non-undefined fallback that satisfies the wire type:
593
+ // - `null` when the wire type accepts null
594
+ // - the string-defaulting `defaultForType(field.type)` (e.g. `''`)
595
+ // for required-string wires
596
+ const baselineWireField = baselineResponse?.fields?.[wire];
597
+ const wireRequired = baselineWireField ? !baselineWireField.optional : field.required;
598
+ if (fallback === 'undefined' && wireRequired) {
599
+ const wireAcceptsNull = baselineWireField?.type?.includes('null');
600
+ fallback = wireAcceptsNull ? 'null' : (defaultForType(field.type) ?? 'undefined');
601
+ }
515
602
  return { kind: 'null-check', fallback };
516
603
  }
517
604
 
518
- // Optional domain → required wire: needs coalesce or assert
519
605
  const baselineWireField = baselineResponse?.fields?.[wire];
520
606
  const baselineDomainField = baselineDomain?.fields?.[domain];
521
607
  const isNewFieldOnExistingDomain = baselineDomain && !baselineDomainField;
@@ -532,7 +618,6 @@ function planSerializeGuard(
532
618
  return { kind: 'non-null-assert' };
533
619
  }
534
620
 
535
- // Nullable with direct assignment → may need coalesce for domain-response mismatch
536
621
  if (field.type.kind === 'nullable' && expr === domainAccess) {
537
622
  const domainWireField2 = wireFieldName(field.name);
538
623
  const responseBaselineField2 = baselineResponse?.fields?.[domainWireField2];
@@ -544,26 +629,23 @@ function planSerializeGuard(
544
629
  responseBaselineField2.optional;
545
630
  const fieldEffectivelyOptional = !field.required || !!isNewSerField || !!domainResponseMismatch;
546
631
  if (fieldEffectivelyOptional) {
547
- return { kind: 'coalesce', fallback: 'null' };
632
+ // The wire side may not accept `null` (e.g. `metadata?: Record<...>`).
633
+ // Fall back to `undefined` in that case so the assignment matches the
634
+ // baseline wire field's actual type.
635
+ const wireAcceptsNull = responseBaselineField2?.type?.includes('null') ?? true;
636
+ const fallback = wireAcceptsNull ? 'null' : 'undefined';
637
+ return { kind: 'coalesce', fallback };
548
638
  }
549
639
  }
550
640
 
551
641
  return { kind: 'direct' };
552
642
  }
553
643
 
554
- // ---------------------------------------------------------------------------
555
- // Field assignment emission — single function for all guard strategies
556
- // ---------------------------------------------------------------------------
557
-
558
644
  function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: GuardStrategy): string {
559
645
  switch (guard.kind) {
560
646
  case 'direct':
561
647
  return ` ${lhs}: ${expr},`;
562
648
  case 'null-check':
563
- // If the expression already contains a null guard from nullable type handling
564
- // (e.g., `response.x != null ? deserializeFoo(response.x) : null`),
565
- // emit it directly — the fallback was baked into the expression.
566
- // Otherwise, wrap with an outer null check using the accessor.
567
649
  if (expr.includes(`${accessExpr} != null ?`)) {
568
650
  return ` ${lhs}: ${expr},`;
569
651
  }
@@ -587,7 +669,6 @@ interface SerializerContext {
587
669
  ctx: EmitterContext;
588
670
  }
589
671
 
590
- /** Build the import block for a serializer file. */
591
672
  export function buildSerializerImports(
592
673
  model: Model,
593
674
  serializerPath: string,
@@ -598,7 +679,10 @@ export function buildSerializerImports(
598
679
  ): string[] {
599
680
  const lines: string[] = [];
600
681
  const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
601
- lines.push(`import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`);
682
+ // Single-form baselines (`wireInterfaceName` returns the same name as
683
+ // `domainName`) only export one symbol — don't duplicate the import.
684
+ const symbols = domainName === responseName ? domainName : `${domainName}, ${responseName}`;
685
+ lines.push(`import type { ${symbols} } from '${relativeImport(serializerPath, interfacePath)}';`);
602
686
 
603
687
  const nestedModelRefs = new Set<string>();
604
688
  for (const field of model.fields) {
@@ -610,13 +694,71 @@ export function buildSerializerImports(
610
694
  for (const dep of nestedModelRefs) {
611
695
  const depService = sctx.modelToService.get(dep);
612
696
  const depDir = sctx.resolveDir(depService);
613
- const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
614
697
  const depName = resolveInterfaceName(dep, sctx.ctx);
698
+
699
+ // Locate the serializer file, in priority order:
700
+ // 1. The actual file containing `deserialize${depName}` per
701
+ // live-surface (e.g. `deserializeAuditLogSchema` lives in
702
+ // `create-audit-log-schema.serializer.ts`, not in the predictable
703
+ // `audit-log-schema.serializer.ts`).
704
+ // 2. The baseline interface's adjacent serializer file path.
705
+ // 3. The IR-name path — this is where the emitter writes the
706
+ // serializer it's producing this run.
707
+ const baselineSrc = (sctx.ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
708
+ const baselineSerializerPath = baselineSrc
709
+ ? baselineSrc.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
710
+ : null;
711
+ const irNameSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
712
+
713
+ const liveDeserPath = liveSurfaceFunctionPath(`deserialize${depName}`);
714
+ const liveSerPath = liveSurfaceFunctionPath(`serialize${depName}`);
715
+ const depSerializerPath =
716
+ liveDeserPath ??
717
+ liveSerPath ??
718
+ (baselineSerializerPath && liveSurfaceHasFile(baselineSerializerPath)
719
+ ? baselineSerializerPath
720
+ : irNameSerializerPath);
721
+
615
722
  const rel = relativeImport(serializerPath, depSerializerPath);
616
- // Check the canonical name for dedup'd models
617
723
  const canon = sctx.dedup.get(dep);
618
724
  const depSkipSerialize =
619
725
  sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
726
+
727
+ // Decide whether this serializer is reachable at runtime:
728
+ // - file on disk → honor what it exports (hasDeser/hasSer)
729
+ // - file NOT on disk → only safe to import if the emitter is
730
+ // producing the dep's serializer this run, which only happens
731
+ // when `modelHasNewFields` says the dep needs regeneration.
732
+ //
733
+ // Skip the import otherwise. The serializer body falls back to a
734
+ // pass-through expression when it can't call the helper.
735
+ const hasDeser = liveSurfaceHasFunction(`deserialize${depName}`);
736
+ const hasSer = liveSurfaceHasFunction(`serialize${depName}`);
737
+ const fileExists = liveSurfaceHasFile(depSerializerPath);
738
+ if (fileExists && !hasDeser && !hasSer) continue;
739
+ if (!fileExists) {
740
+ const depModel = sctx.ctx.spec.models.find((m) => m.name === dep);
741
+ const willGenerateSerializer = depModel ? modelHasNewFields(depModel, sctx.ctx) : true;
742
+ if (!willGenerateSerializer) continue;
743
+ }
744
+
745
+ // Mixed: file exists, only one of the pair is exported. Import only
746
+ // what's present so we don't synthesize a missing symbol. The body
747
+ // emitter's `bodyArgExpr` already falls through when it sees a missing
748
+ // serialize function.
749
+ if (fileExists && depSkipSerialize) {
750
+ if (hasDeser) lines.push(`import { deserialize${depName} } from '${rel}';`);
751
+ continue;
752
+ }
753
+ if (fileExists && !hasSer) {
754
+ lines.push(`import { deserialize${depName} } from '${rel}';`);
755
+ continue;
756
+ }
757
+ if (fileExists && !hasDeser) {
758
+ lines.push(`import { serialize${depName} } from '${rel}';`);
759
+ continue;
760
+ }
761
+
620
762
  if (depSkipSerialize) {
621
763
  lines.push(`import { deserialize${depName} } from '${rel}';`);
622
764
  } else {
@@ -627,7 +769,6 @@ export function buildSerializerImports(
627
769
  return lines;
628
770
  }
629
771
 
630
- /** Build the set of field names where format conversion should be skipped. */
631
772
  export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
632
773
  const skipFormatFields = new Set<string>();
633
774
  if (baselineDomain) {
@@ -635,7 +776,6 @@ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInte
635
776
  if (skipFormatFields.has(field.name)) continue;
636
777
  const baselineField = baselineDomain.fields?.[fieldName(field.name)];
637
778
  if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
638
- // Always convert date-time fields to Date regardless of baseline
639
779
  if (hasDateTimeConversion(field.type)) continue;
640
780
  skipFormatFields.add(field.name);
641
781
  }
@@ -644,7 +784,6 @@ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInte
644
784
  return skipFormatFields;
645
785
  }
646
786
 
647
- /** Check if serialize should be skipped (baseline incompat or cascading dependency). */
648
787
  export function shouldSkipSerializeForModel(
649
788
  model: Model,
650
789
  baselineResponse: BaselineInterface | undefined,
@@ -673,7 +812,6 @@ export function shouldSkipSerializeForModel(
673
812
  return shouldSkip;
674
813
  }
675
814
 
676
- /** Emit deserializer + serializer body lines for a model. */
677
815
  export function emitSerializerBody(
678
816
  model: Model,
679
817
  domainName: string,
@@ -687,7 +825,6 @@ export function emitSerializerBody(
687
825
  ): string[] {
688
826
  const lines: string[] = [];
689
827
 
690
- // Deserialize function (wire → domain)
691
828
  const seenDeserFields = new Set<string>();
692
829
  const deserParamPrefix = model.fields.length === 0 ? '_' : '';
693
830
  lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
@@ -702,7 +839,6 @@ export function emitSerializerBody(
702
839
  }
703
840
  lines.push('});');
704
841
 
705
- // Serialize function (domain → wire)
706
842
  if (!shouldSkipSerialize) {
707
843
  const serParamPrefix = model.fields.length === 0 ? '_' : '';
708
844
  lines.push('');
@@ -1,13 +1,8 @@
1
1
  import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { wireFieldName, fileName, resolveServiceDir } from './naming.js';
3
- import { resolveResourceClassName } from './resources.js';
3
+ import { resolveResourceClassName, resolveResourceDir } from './resources.js';
4
4
  import { createServiceDirResolver, assignModelsToServices, isListMetadataModel, isListWrapperModel } from './utils.js';
5
5
 
6
- /**
7
- * Prefix mapping for generating realistic ID fixture values.
8
- * When a field named "id" belongs to a model whose name matches a key here,
9
- * the generated ID will be prefixed accordingly (e.g. "conn_01234").
10
- */
11
6
  export const ID_PREFIXES: Record<string, string> = {
12
7
  Connection: 'conn_',
13
8
  Organization: 'org_',
@@ -24,10 +19,6 @@ export const ID_PREFIXES: Record<string, string> = {
24
19
  PasswordReset: 'password_reset_',
25
20
  };
26
21
 
27
- /**
28
- * Generate JSON fixture files for test data.
29
- * Each model that appears as a response gets a fixture in wire format (snake_case).
30
- */
31
22
  export function generateFixtures(
32
23
  spec: {
33
24
  models: Model[];
@@ -48,12 +39,11 @@ export function generateFixtures(
48
39
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
49
40
  const files: { path: string; content: string }[] = [];
50
41
 
51
- // Only generate fixtures for models reachable from non-event operations
52
42
  const fixtureSeeds = new Set<string>();
53
43
  for (const svc of spec.services) {
54
44
  if (svc.name.toLowerCase() === 'events') continue;
55
45
  for (const op of svc.operations) {
56
- const collectFromRef = (t: import('@workos/oagen').TypeRef | undefined): void => {
46
+ const collectFromRef = (t: TypeRef | undefined): void => {
57
47
  if (!t) return;
58
48
  if (t.kind === 'model') fixtureSeeds.add(t.name);
59
49
  if (t.kind === 'array') collectFromRef(t.items);
@@ -75,7 +65,7 @@ export function generateFixtures(
75
65
  const m = fixtureModelMap.get(name);
76
66
  if (!m) continue;
77
67
  for (const field of m.fields) {
78
- const walk = (t: import('@workos/oagen').TypeRef): void => {
68
+ const walk = (t: TypeRef): void => {
79
69
  if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
80
70
  if (t.kind === 'array') walk(t.items);
81
71
  if (t.kind === 'nullable') walk(t.inner);
@@ -84,6 +74,7 @@ export function generateFixtures(
84
74
  walk(field.type);
85
75
  }
86
76
  }
77
+
87
78
  const seenFixturePaths = new Set<string>();
88
79
  for (const model of spec.models) {
89
80
  if (!fixtureReachable.has(model.name)) continue;
@@ -94,8 +85,6 @@ export function generateFixtures(
94
85
  const dirName = resolveDir(service);
95
86
  const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
96
87
 
97
- // After noise suffix stripping, multiple models may resolve to the same
98
- // fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
99
88
  if (seenFixturePaths.has(fixturePath)) continue;
100
89
  seenFixturePaths.add(fixturePath);
101
90
 
@@ -107,16 +96,13 @@ export function generateFixtures(
107
96
  });
108
97
  }
109
98
 
110
- // Generate list fixtures for models that appear in paginated responses
111
99
  for (const service of spec.services) {
112
100
  const resolvedName = ctx ? resolveResourceClassName(service, ctx) : service.name;
113
- const serviceDir = resolveServiceDir(resolvedName);
101
+ const serviceDir = ctx ? resolveResourceDir(service, ctx) : resolveServiceDir(resolvedName);
114
102
  for (const op of service.operations) {
115
103
  if (op.pagination) {
116
104
  let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
117
105
  if (itemModel) {
118
- // Detect if the "item" model is actually a list wrapper (has `data` array + `list_metadata`).
119
- // If so, unwrap to the actual item type to avoid double-nesting in fixtures.
120
106
  const unwrapped = unwrapListModel(itemModel, modelMap);
121
107
  if (unwrapped) {
122
108
  itemModel = unwrapped;
@@ -141,12 +127,6 @@ export function generateFixtures(
141
127
  return files;
142
128
  }
143
129
 
144
- /**
145
- * Detect if a model is a list wrapper (has a `data` array field and a `list_metadata` field).
146
- * If so, return the inner item model from the `data` array. Otherwise return null.
147
- * This prevents double-nesting when the pagination itemType points to a list wrapper
148
- * instead of the actual item model.
149
- */
150
130
  export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
151
131
  const dataField = model.fields.find((f) => f.name === 'data');
152
132
  const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
@@ -159,7 +139,7 @@ export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Mod
159
139
  return null;
160
140
  }
161
141
 
162
- function generateModelFixture(
142
+ export function generateModelFixture(
163
143
  model: Model,
164
144
  modelMap: Map<string, Model>,
165
145
  enumMap: Map<string, Enum>,
@@ -168,7 +148,6 @@ function generateModelFixture(
168
148
 
169
149
  for (const field of model.fields) {
170
150
  const wireName = wireFieldName(field.name);
171
- // Prefer the OpenAPI example value when available on the field
172
151
  if (field.example !== undefined) {
173
152
  fixture[wireName] = field.example;
174
153
  } else {
@@ -181,14 +160,14 @@ function generateModelFixture(
181
160
 
182
161
  function generateFieldValue(
183
162
  ref: TypeRef,
184
- fieldName: string,
163
+ fName: string,
185
164
  modelName: string,
186
165
  modelMap: Map<string, Model>,
187
166
  enumMap: Map<string, Enum>,
188
167
  ): any {
189
168
  switch (ref.kind) {
190
169
  case 'primitive':
191
- return generatePrimitiveValue(ref.type, ref.format, fieldName, modelName);
170
+ return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
192
171
  case 'literal':
193
172
  return ref.value;
194
173
  case 'enum': {
@@ -201,21 +180,20 @@ function generateFieldValue(
201
180
  return {};
202
181
  }
203
182
  case 'array': {
204
- // For array<enum>, use actual enum values instead of a single generated item
205
183
  if (ref.items.kind === 'enum') {
206
184
  const e = enumMap.get(ref.items.name);
207
185
  if (e && e.values.length > 0) {
208
186
  return e.values.map((v) => v.value);
209
187
  }
210
188
  }
211
- const item = generateFieldValue(ref.items, fieldName, modelName, modelMap, enumMap);
189
+ const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
212
190
  return [item];
213
191
  }
214
192
  case 'nullable':
215
- return generateFieldValue(ref.inner, fieldName, modelName, modelMap, enumMap);
193
+ return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
216
194
  case 'union':
217
195
  if (ref.variants.length > 0) {
218
- return generateFieldValue(ref.variants[0], fieldName, modelName, modelMap, enumMap);
196
+ return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
219
197
  }
220
198
  return null;
221
199
  case 'map':