@workos/oagen-emitters 0.4.0 → 0.5.0

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 (105) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +8 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -263,17 +263,15 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
263
263
 
264
264
  for (const service of mergedServices) {
265
265
  if (isServiceCoveredByExisting(service, ctx)) {
266
- // Fully covered: generate with ALL operations so the merger's docstring
267
- // refresh pass can update JSDoc on existing methods.
268
- const file = generateResourceClass(service, ctx);
269
- // When the baseline class is missing methods for some operations,
270
- // remove skipIfExists so the merger adds the new methods.
271
- if (hasMethodsAbsentFromBaseline(service, ctx)) {
272
- delete file.skipIfExists;
273
- // Suppress auto-generated header — the file is a merge target
274
- // containing hand-written code, not a fully generated file.
275
- file.headerPlacement = 'skip';
266
+ if (!hasMethodsAbsentFromBaseline(service, ctx)) {
267
+ continue; // Fully covered, no new methods -- skip entirely
276
268
  }
269
+ // Partially covered -- has new methods that need to be added.
270
+ const file = generateResourceClass(service, ctx);
271
+ delete file.skipIfExists;
272
+ // Suppress auto-generated header — the file is a merge target
273
+ // containing hand-written code, not a fully generated file.
274
+ file.headerPlacement = 'skip';
277
275
  files.push(file);
278
276
  continue;
279
277
  }
@@ -307,6 +305,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
307
305
  // stable. Placing them under `interfaces/` lets the per-service barrel
308
306
  // pick them up automatically.
309
307
  for (const service of mergedServices) {
308
+ if (isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx)) continue;
310
309
  files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
311
310
  }
312
311
 
@@ -384,11 +383,19 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
384
383
  const paramEnums = new Set<string>();
385
384
  const paramModels = new Set<string>();
386
385
  for (const { op, plan, method } of plans) {
387
- // Skip imports for methods that already exist in the baseline class.
388
- // The merger keeps baseline method bodies, so their imports are already
389
- // present in the existing file. Including them here would create
390
- // orphaned imports when the generated return type differs from the
391
- // baseline's (e.g., generated `List` vs baseline `RoleList`).
386
+ // Always collect param type refs for enums inline options interfaces
387
+ // are generated for all methods (including baseline ones), so their
388
+ // type dependencies must always be imported.
389
+ const queryParams = plan.isPaginated
390
+ ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
391
+ : op.queryParams;
392
+ for (const param of [...queryParams, ...op.pathParams]) {
393
+ collectParamTypeRefs(param.type, paramEnums, paramModels);
394
+ }
395
+
396
+ // Skip response/request model imports for methods that already exist in
397
+ // the baseline class. The merger keeps baseline method bodies, so their
398
+ // imports are already present in the existing file.
392
399
  if (baselineMethodSet.has(method)) continue;
393
400
 
394
401
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
@@ -427,15 +434,6 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
427
434
  }
428
435
  }
429
436
  }
430
- // Collect types referenced in query and path parameters.
431
- // For paginated operations, skip standard pagination params (limit, before, after, order)
432
- // since they're handled by PaginationOptions and don't need explicit imports.
433
- const queryParams = plan.isPaginated
434
- ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
435
- : op.queryParams;
436
- for (const param of [...queryParams, ...op.pathParams]) {
437
- collectParamTypeRefs(param.type, paramEnums, paramModels);
438
- }
439
437
  }
440
438
 
441
439
  // Collect response models from union split wrappers so their types and
@@ -467,8 +465,8 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
467
465
  lines.push("import type { WorkOS } from '../workos';");
468
466
  if (hasPaginated) {
469
467
  lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
470
- lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
471
- lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
468
+ lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
469
+ lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
472
470
  }
473
471
 
474
472
  // Paginated list options live in their own interface files so they're
@@ -731,12 +729,16 @@ function renderMethod(
731
729
  if (op.description) docParts.push(op.description);
732
730
  for (const param of op.pathParams) {
733
731
  const paramName = fieldName(param.name);
734
- if (validParamNames && !validParamNames.has(paramName)) continue;
732
+ // When the overlay folds a path param into the options object,
733
+ // document it as @param options.paramName instead of top-level.
734
+ const folded = validParamNames && !validParamNames.has(paramName) && validParamNames.has('options');
735
+ if (validParamNames && !validParamNames.has(paramName) && !folded) continue;
736
+ const docName = folded ? `options.${paramName}` : paramName;
735
737
  const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
736
738
  if (param.description) {
737
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
739
+ docParts.push(`@param ${docName} - ${deprecatedPrefix}${param.description}`);
738
740
  } else if (param.deprecated) {
739
- docParts.push(`@param ${paramName} - (deprecated)`);
741
+ docParts.push(`@param ${docName} - (deprecated)`);
740
742
  }
741
743
  if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
742
744
  if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
@@ -763,36 +765,122 @@ function renderMethod(
763
765
  }
764
766
  // Skip header and cookie params in JSDoc — they are not exposed in the method signature.
765
767
  // The SDK handles headers and cookies internally, so documenting them would be misleading.
766
- // Document payload parameter when there is a request body
768
+ // Document payload/body parameter when there is a request body.
769
+ // Detect the actual param name from the overlay — the hand-written SDK may
770
+ // fold the body into an 'options' param instead of the generated 'payload'.
771
+ let bodyDocParamName: string | null = null;
767
772
  if (plan.hasBody) {
768
- const bodyInfo = extractRequestBodyType(op, ctx);
769
- if (bodyInfo?.kind === 'model') {
770
- const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
771
- let payloadDesc: string;
772
- if (bodyModel?.description) {
773
- payloadDesc = `@param payload - ${bodyModel.description}`;
774
- } else if (bodyModel) {
775
- // When the model lacks a description, list its required fields to help
776
- // callers understand what must be provided.
777
- const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
778
- payloadDesc =
779
- requiredFieldNames.length > 0
780
- ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
781
- : '@param payload - The request body.';
773
+ let bodyParamName = 'payload';
774
+ let overlayHadUnusableName = false;
775
+ if (validParamNames && !validParamNames.has('payload') && overlayMethod) {
776
+ const pathNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
777
+ const candidates = overlayMethod.params.filter((p) => !pathNames.has(p.name) && p.name !== 'requestOptions');
778
+ // Filter out destructuring artifacts (e.g., __0) from extracted param names
779
+ const isUsableName = (name: string) => !/^__\d+$/.test(name);
780
+ if (candidates.length === 1 && isUsableName(candidates[0].name)) {
781
+ bodyParamName = candidates[0].name;
782
+ } else if (candidates.length === 1 && !isUsableName(candidates[0].name)) {
783
+ overlayHadUnusableName = true;
784
+ } else if (candidates.length > 1) {
785
+ // Multiple non-path params match by type against the request body model
786
+ const bodyInfo = extractRequestBodyType(op, ctx);
787
+ if (bodyInfo?.kind === 'model') {
788
+ const bodyTypeName = resolveInterfaceName(bodyInfo.name, ctx);
789
+ const typeMatch = candidates.find((p) => p.type === bodyTypeName && isUsableName(p.name));
790
+ if (typeMatch) {
791
+ bodyParamName = typeMatch.name;
792
+ } else {
793
+ // Type names diverge (overlay vs spec). Fall back to matching
794
+ // overlay param names against body model field names so each
795
+ // extracted param gets documented individually.
796
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
797
+ if (bodyModel) {
798
+ const fieldMap = new Map(bodyModel.fields.map((f) => [fieldName(f.name), f]));
799
+ for (const candidate of candidates) {
800
+ const matchingField = fieldMap.get(candidate.name);
801
+ if (matchingField?.description) {
802
+ docParts.push(`@param ${candidate.name} - ${matchingField.description}`);
803
+ if (matchingField.example !== undefined)
804
+ docParts.push(`@example ${JSON.stringify(matchingField.example)}`);
805
+ }
806
+ }
807
+ bodyDocParamName = '__multi__'; // prevent duplicate body doc below
808
+ }
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ // When the overlay had an unusable param name (e.g., destructured __0),
815
+ // force documentation under 'payload' so the body isn't silently dropped.
816
+ if (
817
+ bodyDocParamName !== '__multi__' &&
818
+ (overlayHadUnusableName || !validParamNames || validParamNames.has(bodyParamName))
819
+ ) {
820
+ bodyDocParamName = bodyParamName;
821
+ const bodyInfo = extractRequestBodyType(op, ctx);
822
+ if (bodyInfo?.kind === 'model') {
823
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
824
+ let bodyDesc: string;
825
+ if (bodyModel?.description) {
826
+ bodyDesc = `@param ${bodyParamName} - ${bodyModel.description}`;
827
+ } else if (bodyModel) {
828
+ // When the model lacks a description, list its required fields to help
829
+ // callers understand what must be provided.
830
+ const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
831
+ bodyDesc =
832
+ requiredFieldNames.length > 0
833
+ ? `@param ${bodyParamName} - Object containing ${requiredFieldNames.join(', ')}.`
834
+ : `@param ${bodyParamName} - The request body.`;
835
+ } else {
836
+ bodyDesc = `@param ${bodyParamName} - The request body.`;
837
+ }
838
+ docParts.push(bodyDesc);
839
+
840
+ // When the body is folded into an options-style param (not 'payload'),
841
+ // expand individual fields so IDEs surface per-field documentation.
842
+ if (bodyParamName !== 'payload' && bodyModel) {
843
+ for (const bField of bodyModel.fields) {
844
+ const fName = `${bodyParamName}.${fieldName(bField.name)}`;
845
+ const deprecatedPrefix = bField.deprecated ? '(deprecated) ' : '';
846
+ if (bField.description) {
847
+ docParts.push(`@param ${fName} - ${deprecatedPrefix}${bField.description}`);
848
+ } else if (bField.deprecated) {
849
+ docParts.push(`@param ${fName} - (deprecated)`);
850
+ }
851
+ if (bField.example !== undefined) docParts.push(`@example ${JSON.stringify(bField.example)}`);
852
+ }
853
+ }
782
854
  } else {
783
- payloadDesc = '@param payload - The request body.';
855
+ docParts.push(`@param ${bodyParamName} - The request body.`);
784
856
  }
785
- docParts.push(payloadDesc);
786
- } else {
787
- docParts.push('@param payload - The request body.');
788
857
  }
789
858
  }
790
859
  // Document options parameter for paginated operations
860
+ const hasOptionsSummary = () => docParts.some((p) => /^@param options(\s|$)/.test(p));
791
861
  if (plan.isPaginated) {
792
862
  docParts.push('@param options - Pagination and filter options.');
793
- } else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
863
+ } else if (!hasOptionsSummary() && op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
794
864
  docParts.push('@param options - Additional query options.');
795
865
  }
866
+ // Ensure an @param options summary exists when there are @param options.xxx
867
+ // entries (from folded path/body params) or when the overlay exposes an
868
+ // options param that wasn't otherwise documented.
869
+ if (validParamNames?.has('options') && !hasOptionsSummary()) {
870
+ const hasOptionsDotEntries = docParts.some((p) => p.startsWith('@param options.'));
871
+ if (hasOptionsDotEntries || overlayMethod) {
872
+ docParts.push('@param options - The request options.');
873
+ }
874
+ }
875
+ // Reorder: ensure @param options summary comes before any @param options.xxx entries
876
+ {
877
+ const summaryIdx = docParts.findIndex((p) => /^@param options(\s|$)/.test(p));
878
+ const firstDotIdx = docParts.findIndex((p) => p.startsWith('@param options.'));
879
+ if (summaryIdx > firstDotIdx && firstDotIdx >= 0) {
880
+ const [summary] = docParts.splice(summaryIdx, 1);
881
+ docParts.splice(firstDotIdx, 0, summary);
882
+ }
883
+ }
796
884
  // @returns for the primary response model.
797
885
  // When an overlay method exists, prefer its return type so the JSDoc
798
886
  // matches the actual TypeScript signature (the overlay may use a
@@ -924,10 +1012,26 @@ function renderPaginatedMethod(
924
1012
  const pathParams = buildPathParams(op, specEnumNames);
925
1013
  const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
926
1014
 
1015
+ const wireType = wireInterfaceName(itemType);
1016
+ const serializeCall = serializerArg ? `options ? serialize${optionsType}(options) : undefined` : 'options';
1017
+
927
1018
  lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
928
- lines.push(
929
- ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options${serializerArg});`,
930
- );
1019
+ lines.push(` return new AutoPaginatable(`);
1020
+ lines.push(` await fetchAndDeserialize<${wireType}, ${itemType}>(`);
1021
+ lines.push(` this.workos,`);
1022
+ lines.push(` ${pathStr},`);
1023
+ lines.push(` deserialize${itemType},`);
1024
+ lines.push(` ${serializeCall},`);
1025
+ lines.push(` ),`);
1026
+ lines.push(` (params) =>`);
1027
+ lines.push(` fetchAndDeserialize<${wireType}, ${itemType}>(`);
1028
+ lines.push(` this.workos,`);
1029
+ lines.push(` ${pathStr},`);
1030
+ lines.push(` deserialize${itemType},`);
1031
+ lines.push(` params,`);
1032
+ lines.push(` ),`);
1033
+ lines.push(` options,`);
1034
+ lines.push(` );`);
931
1035
  lines.push(' }');
932
1036
  }
933
1037
 
package/src/node/tests.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  relativeImport,
21
21
  isListMetadataModel,
22
22
  isListWrapperModel,
23
+ modelHasNewFields,
24
+ computeNonEventReachable,
23
25
  } from './utils.js';
24
26
  import { groupByMount } from '../shared/resolved-ops.js';
25
27
 
@@ -29,7 +31,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
29
31
  // Generate fixture JSON files
30
32
  const fixtures = generateFixtures(spec, ctx);
31
33
  for (const f of fixtures) {
32
- files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
34
+ files.push({ path: f.path, content: f.content, headerPlacement: 'skip', skipIfExists: true });
33
35
  }
34
36
 
35
37
  // Build model lookup for response field assertions
@@ -54,11 +56,33 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
54
56
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
55
57
  : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
56
58
 
59
+ // When integrating into an existing SDK, only generate tests for services
60
+ // that have a registered property on the WorkOS class. The WorkOS client
61
+ // file uses skipIfExists, so new service properties aren't added — tests
62
+ // referencing workos.<service> would fail at compile time.
63
+ const baselineWorkOSProps = new Set<string>();
64
+ if (ctx.apiSurface?.classes?.['WorkOS']?.methods) {
65
+ // The extractor stores property accessors as methods
66
+ for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].methods)) {
67
+ baselineWorkOSProps.add(name);
68
+ }
69
+ }
70
+ if (ctx.apiSurface?.classes?.['WorkOS']?.properties) {
71
+ for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].properties)) {
72
+ baselineWorkOSProps.add(name);
73
+ }
74
+ }
75
+
57
76
  for (const { name: mountName, operations } of testEntries) {
58
77
  if (operations.length === 0) continue;
59
78
  const mergedService: Service = { name: mountName, operations };
60
79
  const ops = uncoveredOperations(mergedService, ctx);
61
80
  if (ops.length === 0) continue;
81
+
82
+ // Skip tests for services without a WorkOS property in the baseline
83
+ const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
84
+ if (ctx.apiSurface && baselineWorkOSProps.size > 0 && !baselineWorkOSProps.has(propName)) continue;
85
+
62
86
  const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
63
87
  files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
64
88
  }
@@ -155,7 +179,7 @@ function generateServiceTest(
155
179
  // List fixtures are always generated in the current service's directory
156
180
  // (the service owning the list operation), not in the model's home service.
157
181
  // Always use a local import path.
158
- const fixturePath = `./fixtures/list-${fileName(itemModelName)}.fixture.json`;
182
+ const fixturePath = `./fixtures/list-${fileName(itemModelName)}.json`;
159
183
  fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
160
184
  }
161
185
  } else if (plan.responseModelName) {
@@ -163,8 +187,8 @@ function generateServiceTest(
163
187
  const respDir = resolveDir(respService);
164
188
  const fixturePath =
165
189
  respDir === serviceDir
166
- ? `./fixtures/${fileName(plan.responseModelName)}.fixture.json`
167
- : `../${respDir}/fixtures/${fileName(plan.responseModelName)}.fixture.json`;
190
+ ? `./fixtures/${fileName(plan.responseModelName)}.json`
191
+ : `../${respDir}/fixtures/${fileName(plan.responseModelName)}.json`;
168
192
  fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
169
193
  }
170
194
  // NOTE: Request body fixtures are not imported for body tests because
@@ -216,7 +240,7 @@ function generateServiceTest(
216
240
 
217
241
  lines.push('});');
218
242
 
219
- return { path: testPath, content: lines.join('\n'), skipIfExists: true };
243
+ return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
220
244
  }
221
245
 
222
246
  /** Compute the test value for a single path parameter.
@@ -840,12 +864,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
840
864
 
841
865
  // Only generate round-trip tests for models with fields that have serializers generated.
842
866
  // Skip list metadata and list wrapper models since their serializers are not emitted.
867
+ // Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
868
+ // Skip models unreachable from non-event services (no model/serializer files generated).
869
+ const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
843
870
  const eligibleModels = spec.models.filter(
844
- (m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m),
871
+ (m) =>
872
+ nonEventReachable.has(m.name) &&
873
+ modelNeedsRoundTripTest(m) &&
874
+ !isListMetadataModel(m) &&
875
+ !isListWrapperModel(m) &&
876
+ modelHasNewFields(m, ctx),
845
877
  );
846
878
 
847
879
  if (eligibleModels.length === 0) return files;
848
880
 
881
+ // Use the skipped-serialize set computed by the serializer generator.
882
+ // It's stashed on the context during generateSerializers.
883
+ const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
884
+
849
885
  // Group eligible models by service directory for one test file per service
850
886
  const modelsByDir = new Map<string, Model[]>();
851
887
  for (const model of eligibleModels) {
@@ -872,14 +908,20 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
872
908
  const modelDir = resolveDir(service);
873
909
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
874
910
  const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
875
- const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
911
+ const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
876
912
 
877
- serializerImports.push(
878
- `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
879
- );
913
+ if (serializeSkipped.has(model.name)) {
914
+ serializerImports.push(
915
+ `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
916
+ );
917
+ } else {
918
+ serializerImports.push(
919
+ `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
920
+ );
921
+ }
880
922
  const wireName = wireInterfaceName(domainName);
881
923
  interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
882
- const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
924
+ const camelName = toCamelCase(domainName);
883
925
  fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
884
926
  }
885
927
 
@@ -896,29 +938,36 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
896
938
 
897
939
  for (const model of models) {
898
940
  const domainName = resolveInterfaceName(model.name, ctx);
899
- const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
900
- const fixtureName = `${camelDomain}Fixture`;
901
-
941
+ const fixtureName = `${toCamelCase(domainName)}Fixture`;
902
942
  const wireName = wireInterfaceName(domainName);
903
- lines.push(`describe('${domainName}Serializer', () => {`);
904
- lines.push(" it('round-trips through serialize/deserialize', () => {");
905
- // Cast the fixture to the wire-type interface JSON imports are inferred
906
- // with primitive types (e.g., `string`) that don't satisfy literal-typed
907
- // response fields (e.g., `type: "enum"`), so the deserialize call needs
908
- // the explicit cast to compile.
909
- lines.push(` const fixture = ${fixtureName} as ${wireName};`);
910
- lines.push(` const deserialized = deserialize${domainName}(fixture);`);
911
- lines.push(` const reserialized = serialize${domainName}(deserialized);`);
912
- lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
913
- lines.push(' });');
914
- lines.push('});');
943
+
944
+ if (serializeSkipped.has(model.name)) {
945
+ // Deserialize-only test (no serialize function available)
946
+ lines.push(`describe('${domainName}Serializer', () => {`);
947
+ lines.push(" it('deserializes correctly', () => {");
948
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
949
+ lines.push(` const deserialized = deserialize${domainName}(fixture);`);
950
+ lines.push(' expect(deserialized).toBeDefined();');
951
+ lines.push(' });');
952
+ lines.push('});');
953
+ } else {
954
+ // Round-trip test
955
+ lines.push(`describe('${domainName}Serializer', () => {`);
956
+ lines.push(" it('round-trips through serialize/deserialize', () => {");
957
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
958
+ lines.push(` const deserialized = deserialize${domainName}(fixture);`);
959
+ lines.push(` const reserialized = serialize${domainName}(deserialized);`);
960
+ lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
961
+ lines.push(' });');
962
+ lines.push('});');
963
+ }
915
964
  lines.push('');
916
965
  }
917
966
 
918
967
  files.push({
919
968
  path: testPath,
920
969
  content: lines.join('\n'),
921
- skipIfExists: true,
970
+ overwriteExisting: true,
922
971
  });
923
972
  }
924
973
 
@@ -3,7 +3,6 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
3
  import { wireInterfaceName } from './naming.js';
4
4
 
5
5
  export interface MapTypeRefOpts {
6
- stringDates?: boolean;
7
6
  /** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
8
7
  * When present, model refs for generic models get their defaults appended. */
9
8
  genericDefaults?: Map<string, string>;
@@ -13,16 +12,12 @@ export interface MapTypeRefOpts {
13
12
  * Map an IR TypeRef to a TypeScript domain type string.
14
13
  * Domain types use PascalCase model names (e.g., `Organization`).
15
14
  *
16
- * @param opts.stringDates - When true, map `date-time` to `string` instead of `Date`.
17
- * Use this when integrating into an existing SDK that represents timestamps as
18
- * ISO 8601 strings rather than Date objects.
19
15
  * @param opts.genericDefaults - When present, appends default type args to generic model refs.
20
16
  */
21
17
  export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
22
- const primMapper = opts?.stringDates ? mapPrimitiveStringDates : mapPrimitive;
23
18
  const genericDefaults = opts?.genericDefaults;
24
19
  return irMapTypeRef<string>(ref, {
25
- primitive: primMapper,
20
+ primitive: mapPrimitive,
26
21
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
27
22
  model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
28
23
  enum: (r) => r.name,
@@ -74,31 +69,6 @@ function mapPrimitive(ref: PrimitiveType): string {
74
69
  }
75
70
  }
76
71
 
77
- /**
78
- * Map a primitive type using string representation for dates.
79
- * Used when the existing SDK represents timestamps as ISO 8601 strings.
80
- */
81
- function mapPrimitiveStringDates(ref: PrimitiveType): string {
82
- if (ref.format) {
83
- switch (ref.format) {
84
- case 'int64':
85
- return 'bigint';
86
- // date-time intentionally falls through to the string case
87
- }
88
- }
89
- switch (ref.type) {
90
- case 'string':
91
- return 'string';
92
- case 'integer':
93
- case 'number':
94
- return 'number';
95
- case 'boolean':
96
- return 'boolean';
97
- case 'unknown':
98
- return 'any';
99
- }
100
- }
101
-
102
72
  /**
103
73
  * Map an IR PrimitiveType to a TypeScript wire/JSON type string.
104
74
  * Wire types match JSON encoding: date-time stays string, int64 stays string/number.