@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
@@ -12,7 +12,13 @@ import type {
12
12
  import { planOperation } from '@workos/oagen';
13
13
  import { apiClassName, packageSegment, resolveMethodName, ktStringLiteral, className, propertyName } from './naming.js';
14
14
  import { mapTypeRef } from './type-map.js';
15
- import { groupByMount, lookupResolved, buildResolvedLookup, buildHiddenParams } from '../shared/resolved-ops.js';
15
+ import {
16
+ groupByMount,
17
+ lookupResolved,
18
+ buildResolvedLookup,
19
+ buildHiddenParams,
20
+ collectGroupedParamNames,
21
+ } from '../shared/resolved-ops.js';
16
22
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
17
23
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
18
24
  import { isHandwrittenOverride } from './overrides.js';
@@ -72,6 +78,8 @@ interface OpTest {
72
78
  requiredBodyPaths: string[];
73
79
  /** `name=value` pairs required on the query string — asserted via matchingRegex. */
74
80
  requiredQueryAssertions: { name: string; valueRegex: string }[];
81
+ /** Wire field names that must NOT appear as query params (e.g. password on POST). */
82
+ forbiddenQueryParams: string[];
75
83
  /** Assertions on response fields: { kotlinAccessor, expectedExpr }. */
76
84
  responseAssertions: { accessor: string; expectedExpr: string }[];
77
85
  }
@@ -148,7 +156,7 @@ function generateServiceTestClass(
148
156
  const verifyMethods = new Set<string>();
149
157
  for (const t of uniqueTests) {
150
158
  if (!t.canEmitHappyPath) continue;
151
- if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
159
+ if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0 || t.forbiddenQueryParams.length > 0) {
152
160
  verifyMethods.add(t.httpMethod);
153
161
  }
154
162
  }
@@ -160,8 +168,10 @@ function generateServiceTestClass(
160
168
  }
161
169
  const anyBody = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredBodyPaths.length > 0);
162
170
  const anyQuery = uniqueTests.some((t) => t.canEmitHappyPath && t.requiredQueryAssertions.length > 0);
171
+ const anyForbidden = uniqueTests.some((t) => t.canEmitHappyPath && t.forbiddenQueryParams.length > 0);
163
172
  if (anyBody) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath');
164
173
  if (anyQuery) imports.add('com.github.tomakehurst.wiremock.client.WireMock.matching');
174
+ if (anyForbidden) imports.add('com.github.tomakehurst.wiremock.client.WireMock.absent');
165
175
  // assertEquals is needed when any test has response field assertions.
166
176
  if (uniqueTests.some((t) => t.canEmitHappyPath && t.responseAssertions.length > 0)) {
167
177
  imports.add('org.junit.jupiter.api.Assertions.assertEquals');
@@ -217,6 +227,7 @@ function buildOperationTest(
217
227
  if (!svc) return null;
218
228
  const method = resolveMethodName(op, svc, ctx);
219
229
  const plan = planOperation(op);
230
+ const mountPackage = packageSegment(resolved?.mountOn ?? svc.name);
220
231
 
221
232
  const hidden = buildHiddenParams(resolved);
222
233
 
@@ -229,8 +240,18 @@ function buildOperationTest(
229
240
 
230
241
  for (const _pp of op.pathParams) argParts.push(ktStringLiteral('sample-arg'));
231
242
 
232
- const queryFields = op.queryParams.filter((p) => !hidden.has(p.name));
243
+ const groupedParamNames = collectGroupedParamNames(op);
244
+
245
+ const queryFields = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name));
233
246
  const sortedQuery = [...queryFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
247
+ const sharedQueryBodyParams = new Set<string>();
248
+ const bodyModel = resolveBodyModel(op, ctx);
249
+ for (const qp of queryFields) {
250
+ const matchingBodyField = bodyModel?.fields.find((field) => field.name === qp.name);
251
+ if (matchingBodyField && mapTypeRef(qp.type) === mapTypeRef(matchingBodyField.type)) {
252
+ sharedQueryBodyParams.add(qp.name);
253
+ }
254
+ }
234
255
  for (const qp of sortedQuery) {
235
256
  if (!qp.required) break;
236
257
  const val = synthValue(qp.type, ctx, imports);
@@ -242,14 +263,25 @@ function buildOperationTest(
242
263
  if (regex !== null) requiredQueryAssertions.push({ name: qp.name, valueRegex: regex });
243
264
  }
244
265
 
245
- const bodyModel = resolveBodyModel(op, ctx);
266
+ // Parameter group args — emit as named args (they appear after optionals in the signature)
267
+ const groupParamNames = assignGroupParameterNames(op, hidden, queryFields, bodyModel, groupedParamNames);
268
+ for (const group of op.parameterGroups ?? []) {
269
+ const variant = group.variants[0];
270
+ const sealedName = sealedGroupName(group.name);
271
+ const variantName = className(variant.name);
272
+ const variantArgs = variant.parameters.map((_p) => ktStringLiteral('sample-arg')).join(', ');
273
+ imports.add(`com.workos.${mountPackage}.${sealedName}`);
274
+ argParts.push(`${groupParamNames.get(group.name)!} = ${sealedName}.${variantName}(${variantArgs})`);
275
+ }
276
+
246
277
  if (bodyModel) {
247
278
  // Body fields always pass; colliding names are renamed (e.g. slug →
248
279
  // bodySlug) by the resources emitter, so every required body field still
249
280
  // needs a test argument here.
250
- const bodyFields = bodyModel.fields.filter((f) => !hidden.has(f.name));
281
+ const bodyFields = bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name));
251
282
  const sortedBody = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
252
283
  for (const bf of sortedBody) {
284
+ if (sharedQueryBodyParams.has(bf.name)) continue;
253
285
  if (!bf.required) break;
254
286
  const val = synthValue(bf.type, ctx, imports);
255
287
  if (val === null) return null;
@@ -284,6 +316,20 @@ function buildOperationTest(
284
316
  ? buildResponseAssertions(plan2.responseModelName, ctx)
285
317
  : [];
286
318
 
319
+ // For POST/PUT/PATCH with parameter groups, collect all wire field names
320
+ // from the groups — these must NOT appear as query parameters.
321
+ const forbiddenQueryParams: string[] = [];
322
+ const httpUpper = op.httpMethod.toUpperCase();
323
+ if (['POST', 'PUT', 'PATCH'].includes(httpUpper) && (op.parameterGroups?.length ?? 0) > 0) {
324
+ for (const group of op.parameterGroups!) {
325
+ for (const variant of group.variants) {
326
+ for (const p of variant.parameters) {
327
+ if (!forbiddenQueryParams.includes(p.name)) forbiddenQueryParams.push(p.name);
328
+ }
329
+ }
330
+ }
331
+ }
332
+
287
333
  return {
288
334
  method,
289
335
  httpMethod: op.httpMethod.toLowerCase(),
@@ -295,10 +341,69 @@ function buildOperationTest(
295
341
  imports,
296
342
  requiredBodyPaths,
297
343
  requiredQueryAssertions,
344
+ forbiddenQueryParams,
298
345
  responseAssertions,
299
346
  };
300
347
  }
301
348
 
349
+ function assignGroupParameterNames(
350
+ op: Operation,
351
+ hidden: Set<string>,
352
+ queryFields: Operation['queryParams'],
353
+ bodyModel: Model | null,
354
+ groupedParamNames: Set<string> = new Set(),
355
+ ): Map<string, string> {
356
+ const occupiedNames = new Set<string>();
357
+
358
+ for (const pp of op.pathParams) occupiedNames.add(propertyName(pp.name));
359
+ for (const qp of queryFields) occupiedNames.add(propertyName(qp.name));
360
+
361
+ for (const bf of bodyModel?.fields ?? []) {
362
+ if (hidden.has(bf.name) || groupedParamNames.has(bf.name)) continue;
363
+ const natural = propertyName(bf.name);
364
+ if (occupiedNames.has(natural)) {
365
+ occupiedNames.add(`body${natural.charAt(0).toUpperCase()}${natural.slice(1)}`);
366
+ } else {
367
+ occupiedNames.add(natural);
368
+ }
369
+ }
370
+
371
+ const names = new Map<string, string>();
372
+ for (const group of op.parameterGroups ?? []) {
373
+ const natural = propertyName(sealedGroupName(group.name));
374
+ const assigned = reserveUniqueGroupParameterName(natural, occupiedNames);
375
+ names.set(group.name, assigned);
376
+ }
377
+ return names;
378
+ }
379
+
380
+ function sealedGroupName(name: string): string {
381
+ const resolved = className(name);
382
+ if (resolved === 'Password') return 'CreateUserPassword';
383
+ if (resolved === 'Role') return 'CreateUserRole';
384
+ return resolved;
385
+ }
386
+
387
+ function reserveUniqueGroupParameterName(base: string, occupiedNames: Set<string>): string {
388
+ if (!occupiedNames.has(base)) {
389
+ occupiedNames.add(base);
390
+ return base;
391
+ }
392
+
393
+ const capitalized = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
394
+ const prefixed = `group${capitalized}`;
395
+ if (!occupiedNames.has(prefixed)) {
396
+ occupiedNames.add(prefixed);
397
+ return prefixed;
398
+ }
399
+
400
+ let index = 2;
401
+ while (occupiedNames.has(`${prefixed}${index}`)) index += 1;
402
+ const fallback = `${prefixed}${index}`;
403
+ occupiedNames.add(fallback);
404
+ return fallback;
405
+ }
406
+
302
407
  /** True if the synthesized body value serializes to a concrete JSON scalar. */
303
408
  function isScalarBodyField(type: TypeRef): boolean {
304
409
  const inner = type.kind === 'nullable' ? type.inner : type;
@@ -383,6 +488,7 @@ function buildWrapperTest(op: Operation, wrapper: ResolvedWrapper, ctx: EmitterC
383
488
  imports,
384
489
  requiredBodyPaths: [],
385
490
  requiredQueryAssertions: [],
491
+ forbiddenQueryParams: [],
386
492
  responseAssertions,
387
493
  };
388
494
  }
@@ -643,7 +749,7 @@ function emitHappyPathTest(lines: string[], t: OpTest): void {
643
749
  // Verify the outbound request shape. Body fields and query assertions
644
750
  // live on the `OpTest` and are only emitted when we know the synthesized
645
751
  // arguments produce a deterministic wire representation.
646
- if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0) {
752
+ if (t.requiredBodyPaths.length > 0 || t.requiredQueryAssertions.length > 0 || t.forbiddenQueryParams.length > 0) {
647
753
  lines.push(' wireMockRule.verify(');
648
754
  lines.push(` ${t.httpMethod}RequestedFor(urlPathMatching(${ktStringLiteral(t.pathForWireMock)}))`);
649
755
  for (const path of t.requiredBodyPaths) {
@@ -652,6 +758,10 @@ function emitHappyPathTest(lines: string[], t: OpTest): void {
652
758
  for (const qa of t.requiredQueryAssertions) {
653
759
  lines.push(` .withQueryParam(${ktStringLiteral(qa.name)}, matching(${ktStringLiteral(qa.valueRegex)}))`);
654
760
  }
761
+ // Assert sensitive fields from parameter groups never leak into the URL.
762
+ for (const name of t.forbiddenQueryParams) {
763
+ lines.push(` .withQueryParam(${ktStringLiteral(name)}, absent())`);
764
+ }
655
765
  lines.push(' )');
656
766
  }
657
767
  lines.push(' }');
@@ -874,9 +984,13 @@ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): Generat
874
984
  // models, arrays, maps, and literals — much broader than the old
875
985
  // primitives-only filter.
876
986
  const targets: { model: Model; json: string }[] = [];
987
+ const seenModelClassNames = new Set<string>();
877
988
  for (const m of spec.models) {
878
989
  if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
879
990
  if (m.fields.length === 0) continue;
991
+ const cls = className(m.name);
992
+ if (seenModelClassNames.has(cls)) continue;
993
+ seenModelClassNames.add(cls);
880
994
  // Only include models where ALL fields are required AND all types are
881
995
  // round-trip safe (primitives, nullable, literals, simple arrays/maps).
882
996
  // Nested model/enum references break round-trip because Jackson
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
4
- import { collectReferencedNames } from '@workos/oagen';
4
+
5
5
  import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
6
6
  import {
7
7
  docComment,
@@ -9,6 +9,7 @@ import {
9
9
  isServiceCoveredByExisting,
10
10
  isListMetadataModel,
11
11
  isListWrapperModel,
12
+ computeNonEventReachable,
12
13
  } from './utils.js';
13
14
  import { resolveResourceClassName } from './resources.js';
14
15
 
@@ -186,12 +187,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
186
187
  // Models -> service directories
187
188
  // Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
188
189
  // from common utils, so no per-resource interface file is generated.
189
- // Also skip unreachable models — oagen only passes service-referenced models
190
- // to generateModels, so unreachable models have no interface file to export.
191
- const barrelReachable = collectReferencedNames(spec.services, spec.models);
190
+ // Also skip unreachable models — use the same non-event reachability as model
191
+ // generation so every barrel entry has a corresponding generated file.
192
+ const barrelReachable = computeNonEventReachable(spec.services, spec.models);
192
193
  for (const model of spec.models) {
193
194
  if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
194
- if (!barrelReachable.models.has(model.name)) continue;
195
+ if (!barrelReachable.has(model.name)) continue;
195
196
  const service = modelToService.get(model.name);
196
197
  const dirName = resolveDir(service);
197
198
  if (!dirExports.has(dirName)) {
@@ -265,12 +266,38 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
265
266
  addBaselineExports(ctx.apiSurface.typeAliases);
266
267
  addBaselineExports(ctx.apiSurface.enums);
267
268
 
268
- // Scan the target directory for interface files not captured by the
269
- // api-surface (e.g., list wrappers, hand-written types). Only add
270
- // files whose exported symbols don't collide with symbols already
271
- // claimed by another directory's barrel (TS2308 prevention).
269
+ // Preserve existing barrel entries: read the current barrel from the
270
+ // target directory and keep every `export * from './<stem>'` whose
271
+ // corresponding file still exists on disk. This prevents dropping
272
+ // hand-written types (e.g., Factor in multi-factor-auth) when a
273
+ // generated model in the same file causes a symbol collision.
272
274
  if (ctx.targetDir) {
273
275
  const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
276
+ try {
277
+ const barrelPath = path.join(interfacesDir, 'index.ts');
278
+ const barrelContent = fs.readFileSync(barrelPath, 'utf-8');
279
+ for (const line of barrelContent.split('\n')) {
280
+ const match = line.match(/^export \* from '\.\/(.*?)';?$/);
281
+ if (!match) continue;
282
+ const stem = match[1];
283
+ const exportLine = `export * from './${stem}';`;
284
+ if (exportSet.has(exportLine)) continue;
285
+ // Verify the referenced file still exists
286
+ const filePath = path.join(interfacesDir, `${stem}.ts`);
287
+ try {
288
+ fs.accessSync(filePath);
289
+ exportSet.add(exportLine);
290
+ } catch {
291
+ // File no longer exists — don't preserve stale entry
292
+ }
293
+ }
294
+ } catch {
295
+ // No existing barrel — nothing to preserve
296
+ }
297
+
298
+ // Also scan for NEW interface files not in the existing barrel or
299
+ // apiSurface (e.g., list wrappers, hand-written types added after
300
+ // the last generation).
274
301
  const symbols = dirSymbols.get(dirName) ?? new Set<string>();
275
302
  try {
276
303
  for (const entry of fs.readdirSync(interfacesDir)) {
@@ -545,8 +572,8 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
545
572
  // Filter to reachable models only: oagen's generateAllFiles passes only
546
573
  // service-referenced models to generateModels, so unreachable models
547
574
  // never get interface files. Exporting them here would create broken imports.
548
- const reachable = collectReferencedNames(spec.services, spec.models);
549
- const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.models.has(m.name));
575
+ const reachable = computeNonEventReachable(spec.services, spec.models);
576
+ const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.has(m.name));
550
577
  const commonEnums = spec.enums.filter((e) => {
551
578
  const enumService = findEnumService(e.name, spec.services);
552
579
  return !enumService;
@@ -582,7 +582,6 @@ function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: Gu
582
582
  interface SerializerContext {
583
583
  modelToService: Map<string, string>;
584
584
  resolveDir: (irService: string | undefined) => string;
585
- useStringDates: boolean;
586
585
  dedup: Map<string, string>;
587
586
  skippedSerializeModels: Set<string>;
588
587
  ctx: EmitterContext;
@@ -614,31 +613,30 @@ export function buildSerializerImports(
614
613
  const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
615
614
  const depName = resolveInterfaceName(dep, sctx.ctx);
616
615
  const rel = relativeImport(serializerPath, depSerializerPath);
617
- lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
616
+ // Check the canonical name for dedup'd models
617
+ const canon = sctx.dedup.get(dep);
618
+ const depSkipSerialize =
619
+ sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
620
+ if (depSkipSerialize) {
621
+ lines.push(`import { deserialize${depName} } from '${rel}';`);
622
+ } else {
623
+ lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
624
+ }
618
625
  }
619
626
  lines.push('');
620
627
  return lines;
621
628
  }
622
629
 
623
630
  /** Build the set of field names where format conversion should be skipped. */
624
- export function buildSkipFormatFields(
625
- model: Model,
626
- useStringDates: boolean,
627
- baselineDomain: BaselineInterface | undefined,
628
- ): Set<string> {
631
+ export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
629
632
  const skipFormatFields = new Set<string>();
630
- if (useStringDates) {
631
- for (const field of model.fields) {
632
- if (hasDateTimeConversion(field.type)) {
633
- skipFormatFields.add(field.name);
634
- }
635
- }
636
- }
637
633
  if (baselineDomain) {
638
634
  for (const field of model.fields) {
639
635
  if (skipFormatFields.has(field.name)) continue;
640
636
  const baselineField = baselineDomain.fields?.[fieldName(field.name)];
641
637
  if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
638
+ // Always convert date-time fields to Date regardless of baseline
639
+ if (hasDateTimeConversion(field.type)) continue;
642
640
  skipFormatFields.add(field.name);
643
641
  }
644
642
  }
@@ -48,15 +48,51 @@ export function generateFixtures(
48
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
49
49
  const files: { path: string; content: string }[] = [];
50
50
 
51
+ // Only generate fixtures for models reachable from non-event operations
52
+ const fixtureSeeds = new Set<string>();
53
+ for (const svc of spec.services) {
54
+ if (svc.name.toLowerCase() === 'events') continue;
55
+ for (const op of svc.operations) {
56
+ const collectFromRef = (t: import('@workos/oagen').TypeRef | undefined): void => {
57
+ if (!t) return;
58
+ if (t.kind === 'model') fixtureSeeds.add(t.name);
59
+ if (t.kind === 'array') collectFromRef(t.items);
60
+ if (t.kind === 'nullable') collectFromRef(t.inner);
61
+ if (t.kind === 'union') t.variants.forEach(collectFromRef);
62
+ };
63
+ collectFromRef(op.response);
64
+ collectFromRef(op.requestBody);
65
+ if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
66
+ }
67
+ }
68
+ const fixtureModelMap = new Map(spec.models.map((m: Model) => [m.name, m]));
69
+ const fixtureReachable = new Set<string>();
70
+ const fixtureQueue = [...fixtureSeeds];
71
+ while (fixtureQueue.length > 0) {
72
+ const name = fixtureQueue.pop()!;
73
+ if (fixtureReachable.has(name)) continue;
74
+ fixtureReachable.add(name);
75
+ const m = fixtureModelMap.get(name);
76
+ if (!m) continue;
77
+ for (const field of m.fields) {
78
+ const walk = (t: import('@workos/oagen').TypeRef): void => {
79
+ if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
80
+ if (t.kind === 'array') walk(t.items);
81
+ if (t.kind === 'nullable') walk(t.inner);
82
+ if (t.kind === 'union') t.variants.forEach(walk);
83
+ };
84
+ walk(field.type);
85
+ }
86
+ }
51
87
  const seenFixturePaths = new Set<string>();
52
88
  for (const model of spec.models) {
53
- // Skip redundant list-metadata and list-wrapper models (handled by shared types)
89
+ if (!fixtureReachable.has(model.name)) continue;
54
90
  if (isListMetadataModel(model)) continue;
55
91
  if (isListWrapperModel(model)) continue;
56
92
 
57
93
  const service = modelToService.get(model.name);
58
94
  const dirName = resolveDir(service);
59
- const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.fixture.json`;
95
+ const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
60
96
 
61
97
  // After noise suffix stripping, multiple models may resolve to the same
62
98
  // fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
@@ -94,7 +130,7 @@ export function generateFixtures(
94
130
  },
95
131
  };
96
132
  files.push({
97
- path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.fixture.json`,
133
+ path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.json`,
98
134
  content: JSON.stringify(listFixture, null, 2),
99
135
  });
100
136
  }