@workos/oagen-emitters 0.12.1 → 0.12.2

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 (44) 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 +7 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
  12. package/dist/plugin-eCuvoL1T.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 +345 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +540 -351
  24. package/src/node/naming.ts +119 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/resources.ts +455 -46
  28. package/src/node/sdk-errors.ts +0 -16
  29. package/src/node/tests.ts +108 -83
  30. package/src/node/type-map.ts +40 -18
  31. package/src/node/utils.ts +89 -102
  32. package/src/node/wrappers.ts +0 -20
  33. package/test/node/client.test.ts +106 -1201
  34. package/test/node/enums.test.ts +59 -130
  35. package/test/node/errors.test.ts +2 -3
  36. package/test/node/live-surface.test.ts +240 -0
  37. package/test/node/models.test.ts +396 -765
  38. package/test/node/naming.test.ts +69 -234
  39. package/test/node/resources.test.ts +376 -2036
  40. package/test/node/tests.test.ts +119 -0
  41. package/test/node/type-map.test.ts +49 -54
  42. package/test/node/utils.test.ts +29 -80
  43. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  44. package/test/node/serializers.test.ts +0 -444
@@ -1,18 +1,9 @@
1
1
  import type { SdkBehavior } from '@workos/oagen';
2
2
 
3
- /**
4
- * Node-specific overrides for exception kind names.
5
- *
6
- * The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
7
- * but the Node SDK historically uses different names for some status codes.
8
- * This map translates the IR kind name to the Node-specific name before
9
- * appending the 'Exception' suffix.
10
- */
11
3
  const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
12
4
  Authentication: 'Unauthorized',
13
5
  };
14
6
 
15
- /** Fallback status code map when no SDK behavior is provided. */
16
7
  const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
17
8
  '400': 'BadRequest',
18
9
  '401': 'Authentication',
@@ -23,13 +14,6 @@ const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
23
14
  '429': 'RateLimitExceeded',
24
15
  };
25
16
 
26
- /**
27
- * Build the status-code-to-exception-class-name map from SDK behavior,
28
- * applying Node-specific naming overrides.
29
- *
30
- * Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
31
- * because Node uses `UnauthorizedException` instead of `AuthenticationException`.
32
- */
33
17
  export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
34
18
  const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
35
19
  return Object.fromEntries(
package/src/node/tests.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
4
  import { planOperation, toCamelCase } from '@workos/oagen';
3
5
  import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
@@ -12,7 +14,7 @@ import {
12
14
  wireInterfaceName,
13
15
  } from './naming.js';
14
16
  import { generateFixtures } from './fixtures.js';
15
- import { resolveResourceClassName } from './resources.js';
17
+ import { resolveResourceClassName, resolveResourceDir } from './resources.js';
16
18
  import {
17
19
  assignModelsToServices,
18
20
  createServiceDirResolver,
@@ -24,6 +26,30 @@ import {
24
26
  computeNonEventReachable,
25
27
  } from './utils.js';
26
28
  import { groupByMount } from '../shared/resolved-ops.js';
29
+ import { isNodeOwnedService } from './options.js';
30
+
31
+ type BaselineMethod = {
32
+ params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
33
+ returnType?: string;
34
+ };
35
+
36
+ function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
37
+ const serviceClass = resolveResourceClassName(service, ctx);
38
+ return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
39
+ }
40
+
41
+ function optionsObjectParam(method: BaselineMethod | undefined): { name: string; type: string } | undefined {
42
+ if (!method || method.params.length !== 1) return undefined;
43
+ const [param] = method.params;
44
+ if (param.name !== 'options') return undefined;
45
+ if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
46
+ if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
47
+ return { name: param.name, type: param.type };
48
+ }
49
+
50
+ function autoPaginatableItemType(returnType: string | undefined): string | undefined {
51
+ return returnType?.match(/\bAutoPaginatable<\s*([A-Za-z_$][\w$]*)/)?.[1];
52
+ }
27
53
 
28
54
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
29
55
  const files: GeneratedFile[] = [];
@@ -76,7 +102,8 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
76
102
  for (const { name: mountName, operations } of testEntries) {
77
103
  if (operations.length === 0) continue;
78
104
  const mergedService: Service = { name: mountName, operations };
79
- const ops = uncoveredOperations(mergedService, ctx);
105
+ const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
106
+ const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
80
107
  if (ops.length === 0) continue;
81
108
 
82
109
  // Skip tests for services without a WorkOS property in the baseline
@@ -104,7 +131,7 @@ function generateServiceTest(
104
131
  mountAccessors?: Map<string, string>,
105
132
  ): GeneratedFile {
106
133
  const resolvedName = resolveResourceClassName(service, ctx);
107
- const serviceDir = resolveServiceDir(resolvedName);
134
+ const serviceDir = resolveResourceDir(service, ctx);
108
135
  const serviceClass = resolvedName;
109
136
  const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
110
137
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
@@ -148,11 +175,6 @@ function generateServiceTest(
148
175
  const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
149
176
  if (hasPaginated) testUtils.push('fetchSearchParams');
150
177
  if (hasBody) testUtils.push('fetchBody');
151
- // Import shared test helpers for error and pagination tests
152
- if (hasPaginated) testUtils.push('testEmptyResults', 'testPaginationParams');
153
- // Only import testUnauthorized when at least one operation has a response model or is paginated
154
- const hasErrorTests = plans.some((p) => p.plan.responseModelName || p.plan.isPaginated);
155
- if (hasErrorTests) testUtils.push('testUnauthorized');
156
178
  lines.push('import {');
157
179
  for (const util of testUtils) {
158
180
  lines.push(` ${util},`);
@@ -215,24 +237,20 @@ function generateServiceTest(
215
237
  lines.push(' beforeEach(() => fetch.resetMocks());');
216
238
 
217
239
  for (const { op, plan, method } of plans) {
240
+ const existingMethod = baselineMethodFor(service, method, ctx);
218
241
  lines.push('');
219
242
  lines.push(` describe('${method}', () => {`);
220
243
 
221
244
  if (plan.isPaginated) {
222
- renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
245
+ renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
223
246
  } else if (plan.isDelete) {
224
- renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
247
+ renderDeleteTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
225
248
  } else if (plan.hasBody && plan.responseModelName) {
226
- renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
249
+ renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
227
250
  } else if (plan.responseModelName) {
228
- renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
251
+ renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
229
252
  } else {
230
- renderVoidTest(lines, op, plan, method, serviceProp, modelMap);
231
- }
232
-
233
- // Error case test for all non-void operations
234
- if (plan.responseModelName || plan.isPaginated) {
235
- renderErrorTest(lines, op, plan, method, serviceProp, modelMap);
253
+ renderVoidTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
236
254
  }
237
255
 
238
256
  lines.push(' });');
@@ -281,6 +299,7 @@ function renderPaginatedTest(
281
299
  modelMap: Map<string, Model>,
282
300
  ctx?: EmitterContext,
283
301
  entityHelpers?: Set<string>,
302
+ baselineMethod?: BaselineMethod,
284
303
  ): void {
285
304
  let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
286
305
  // Unwrap list wrapper models to match the fixture file naming in fixtures.ts
@@ -292,11 +311,15 @@ function renderPaginatedTest(
292
311
  }
293
312
  }
294
313
  const pathArgs = buildTestPathArgs(op);
314
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
315
+ const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
316
+ const generatedItemType = ctx ? resolveInterfaceName(itemModelName, ctx) : null;
317
+ const skipFieldAssertions = Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
295
318
 
296
319
  lines.push(" it('returns paginated results', async () => {");
297
320
  lines.push(` fetchOnce(list${itemModelName}Fixture);`);
298
321
  lines.push('');
299
- lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
322
+ lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
300
323
  lines.push('');
301
324
  lines.push(" expect(fetchMethod()).toBe('GET');");
302
325
  // Fix #12: Full URL path assertion instead of toContain()
@@ -308,7 +331,9 @@ function renderPaginatedTest(
308
331
 
309
332
  // Assert on first item fields — use entity helper if available
310
333
  const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
311
- if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
334
+ if (skipFieldAssertions) {
335
+ lines.push(' expect(data.length).toBeGreaterThan(0);');
336
+ } else if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
312
337
  lines.push(' expect(data.length).toBeGreaterThan(0);');
313
338
  lines.push(` ${paginatedHelperName}(data[0]);`);
314
339
  } else {
@@ -325,17 +350,6 @@ function renderPaginatedTest(
325
350
  }
326
351
 
327
352
  lines.push(' });');
328
-
329
- // Edge case: handles empty results — use shared helper
330
- lines.push('');
331
- lines.push(` testEmptyResults(() => workos.${serviceProp}.${method}(${pathArgs}));`);
332
-
333
- // Edge case: forwards pagination params — use shared helper
334
- lines.push('');
335
- lines.push(` testPaginationParams(`);
336
- lines.push(` (opts) => workos.${serviceProp}.${method}(${pathArgs ? pathArgs + ', ' : ''}opts),`);
337
- lines.push(` list${itemModelName}Fixture,`);
338
- lines.push(' );');
339
353
  }
340
354
 
341
355
  function renderDeleteTest(
@@ -345,12 +359,15 @@ function renderDeleteTest(
345
359
  method: string,
346
360
  serviceProp: string,
347
361
  modelMap: Map<string, Model>,
362
+ ctx?: EmitterContext,
363
+ baselineMethod?: BaselineMethod,
348
364
  ): void {
349
365
  const pathArgs = buildTestPathArgs(op);
350
366
  // Build realistic payload for body-bearing delete operations
351
367
  const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
352
368
  const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
353
- const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
369
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
370
+ const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
354
371
 
355
372
  lines.push(" it('sends a DELETE request', async () => {");
356
373
  lines.push(' fetchOnce({}, { status: 204 });');
@@ -380,6 +397,7 @@ function renderBodyTest(
380
397
  modelMap: Map<string, Model>,
381
398
  ctx?: EmitterContext,
382
399
  entityHelpers?: Set<string>,
400
+ baselineMethod?: BaselineMethod,
383
401
  ): void {
384
402
  const responseModelName = plan.responseModelName!;
385
403
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
@@ -388,7 +406,8 @@ function renderBodyTest(
388
406
  // Build realistic payload from request body model fields
389
407
  const payload = buildTestPayload(op, modelMap);
390
408
  const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
391
- const allArgs = pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg;
409
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
410
+ const allArgs = optionsArg ?? (pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg);
392
411
 
393
412
  lines.push(" it('sends the correct request and returns result', async () => {");
394
413
  lines.push(` fetchOnce(${fixture});`);
@@ -440,15 +459,17 @@ function renderGetTest(
440
459
  modelMap: Map<string, Model>,
441
460
  ctx?: EmitterContext,
442
461
  entityHelpers?: Set<string>,
462
+ baselineMethod?: BaselineMethod,
443
463
  ): void {
444
464
  const responseModelName = plan.responseModelName!;
445
465
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
446
466
  const pathArgs = buildTestPathArgs(op);
467
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
447
468
 
448
469
  lines.push(" it('returns the expected result', async () => {");
449
470
  lines.push(` fetchOnce(${fixture});`);
450
471
  lines.push('');
451
- lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
472
+ lines.push(` const result = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
452
473
  lines.push('');
453
474
  lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
454
475
  // Fix #12: Full URL path assertion instead of toContain()
@@ -485,12 +506,15 @@ function renderVoidTest(
485
506
  method: string,
486
507
  serviceProp: string,
487
508
  modelMap: Map<string, Model>,
509
+ ctx?: EmitterContext,
510
+ baselineMethod?: BaselineMethod,
488
511
  ): void {
489
512
  const pathArgs = buildTestPathArgs(op);
490
513
  // Build realistic payload for body-bearing void operations
491
514
  const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
492
515
  const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
493
- const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
516
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
517
+ const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
494
518
 
495
519
  lines.push(" it('sends the request', async () => {");
496
520
  lines.push(' fetchOnce({});');
@@ -507,57 +531,44 @@ function renderVoidTest(
507
531
  lines.push(' });');
508
532
  }
509
533
 
510
- function renderErrorTest(
511
- lines: string[],
534
+ function buildOptionsObjectTestArg(
512
535
  op: Operation,
513
536
  plan: any,
514
- method: string,
515
- serviceProp: string,
537
+ baselineMethod: BaselineMethod | undefined,
516
538
  modelMap: Map<string, Model>,
517
- ): void {
518
- const args = buildCallArgs(op, plan, modelMap);
519
-
520
- lines.push('');
521
- lines.push(` testUnauthorized(() => workos.${serviceProp}.${method}(${args}));`);
522
-
523
- // Add error-status tests based on the operation's error responses
524
- const errorStatuses = new Set(op.errors.map((e) => e.statusCode));
539
+ ctx?: EmitterContext,
540
+ ): string | null {
541
+ const optionParam = optionsObjectParam(baselineMethod);
542
+ if (!optionParam) return null;
525
543
 
526
- // 404 test for find/get methods
527
- if (errorStatuses.has(404) && (method.startsWith('get') || method.startsWith('find'))) {
528
- lines.push('');
529
- lines.push(" it('throws NotFoundException on 404', async () => {");
530
- lines.push(" fetchOnce('', { status: 404 });");
531
- lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
532
- lines.push(' });');
544
+ const entries: string[] = [];
545
+ for (const param of op.pathParams) {
546
+ const localName = fieldName(param.name);
547
+ const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
548
+ entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
533
549
  }
534
550
 
535
- // 422 test for create/update methods
536
- if (errorStatuses.has(422) && (method.startsWith('create') || method.startsWith('update'))) {
537
- lines.push('');
538
- lines.push(" it('throws UnprocessableEntityException on 422', async () => {");
539
- lines.push(" fetchOnce('', { status: 422 });");
540
- lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
541
- lines.push(' });');
551
+ if (plan.hasBody) {
552
+ const payload = buildTestPayload(op, modelMap);
553
+ if (payload) entries.push(...objectLiteralEntries(payload.camelCaseObj));
542
554
  }
555
+
556
+ return `{ ${entries.join(', ')} }`;
543
557
  }
544
558
 
545
- /**
546
- * Build the argument string for a method call in tests.
547
- * Shared by renderErrorTest and other test renderers.
548
- */
549
- function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>): string {
550
- const pathArgs = buildTestPathArgs(op);
551
- const isPaginated = plan.isPaginated;
552
- const hasBody = plan.hasBody;
559
+ function objectLiteralEntries(literal: string): string[] {
560
+ const trimmed = literal.trim();
561
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
562
+ const body = trimmed.slice(1, -1).trim();
563
+ return body ? body.split(',').map((entry) => entry.trim()) : [];
564
+ }
553
565
 
554
- if (isPaginated) return pathArgs || '';
555
- if (hasBody) {
556
- const payload = buildTestPayload(op, modelMap);
557
- const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
558
- return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
559
- }
560
- return pathArgs || '';
566
+ function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
567
+ const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
568
+ if (!fields) return localName;
569
+ if (fields[localName]) return localName;
570
+ if (localName === 'omId' && fields.organizationMembershipId) return 'organizationMembershipId';
571
+ return localName;
561
572
  }
562
573
 
563
574
  /**
@@ -848,6 +859,12 @@ function modelNeedsRoundTripTest(model: Model): boolean {
848
859
  return model.fields.length > 0;
849
860
  }
850
861
 
862
+ function fixtureIsHandOwned(fixturePath: string, ctx: EmitterContext): boolean {
863
+ const root = ctx.outputDir ?? ctx.targetDir;
864
+ if (!root) return false;
865
+ return fs.existsSync(path.join(root, fixturePath));
866
+ }
867
+
851
868
  /**
852
869
  * Generate serializer round-trip tests for models that have both serialize and
853
870
  * deserialize functions and have nested types requiring non-trivial serialization.
@@ -867,14 +884,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
867
884
  // Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
868
885
  // Skip models unreachable from non-event services (no model/serializer files generated).
869
886
  const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
870
- const eligibleModels = spec.models.filter(
871
- (m) =>
887
+ const generatedSerializerModels = (ctx as any)._generatedSerializerModels as Set<string> | undefined;
888
+ const eligibleModels = spec.models.filter((m) => {
889
+ const service = modelToService.get(m.name);
890
+ return (
872
891
  nonEventReachable.has(m.name) &&
873
892
  modelNeedsRoundTripTest(m) &&
874
893
  !isListMetadataModel(m) &&
875
894
  !isListWrapperModel(m) &&
876
- modelHasNewFields(m, ctx),
877
- );
895
+ (generatedSerializerModels?.has(m.name) ?? (modelHasNewFields(m, ctx) || isNodeOwnedService(ctx, service)))
896
+ );
897
+ });
878
898
 
879
899
  if (eligibleModels.length === 0) return files;
880
900
 
@@ -887,6 +907,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
887
907
  for (const model of eligibleModels) {
888
908
  const service = modelToService.get(model.name);
889
909
  const dirName = resolveDir(service);
910
+ const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
911
+ if (!fixtureIsHandOwned(fixturePath, ctx)) continue;
890
912
  if (!modelsByDir.has(dirName)) {
891
913
  modelsByDir.set(dirName, []);
892
914
  }
@@ -901,6 +923,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
901
923
  const serializerImports: string[] = [];
902
924
  const interfaceImports: string[] = [];
903
925
  const fixtureImports: string[] = [];
926
+ const deserializeOnlyModels = new Set<string>();
904
927
 
905
928
  for (const model of models) {
906
929
  const domainName = resolveInterfaceName(model.name, ctx);
@@ -909,8 +932,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
909
932
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
910
933
  const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
911
934
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
935
+ const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
936
+ if (deserializeOnly) deserializeOnlyModels.add(model.name);
912
937
 
913
- if (serializeSkipped.has(model.name)) {
938
+ if (deserializeOnly) {
914
939
  serializerImports.push(
915
940
  `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
916
941
  );
@@ -941,8 +966,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
941
966
  const fixtureName = `${toCamelCase(domainName)}Fixture`;
942
967
  const wireName = wireInterfaceName(domainName);
943
968
 
944
- if (serializeSkipped.has(model.name)) {
945
- // Deserialize-only test (no serialize function available)
969
+ if (deserializeOnlyModels.has(model.name)) {
970
+ // Deserialize-only test for hand-owned fixtures or models without a serializer.
946
971
  lines.push(`describe('${domainName}Serializer', () => {`);
947
972
  lines.push(" it('deserializes correctly', () => {");
948
973
  lines.push(` const fixture = ${fixtureName} as ${wireName};`);
@@ -3,24 +3,56 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
3
  import { wireInterfaceName } from './naming.js';
4
4
 
5
5
  export interface MapTypeRefOpts {
6
- /** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
7
- * When present, model refs for generic models get their defaults appended. */
8
6
  genericDefaults?: Map<string, string>;
9
7
  }
10
8
 
9
+ /**
10
+ * Map of enum name → inlined string-union TS source.
11
+ *
12
+ * Set by `index.ts` once per generation run, sourced from `spec.enums` for
13
+ * enums that have no baseline definition in the live SDK. When populated,
14
+ * `mapTypeRef`/`mapWireTypeRef` substitute the union directly at the
15
+ * reference site instead of emitting a separate import — this collapses
16
+ * ~100 single-line enum files into inline literal types.
17
+ */
18
+ let inlineEnumUnions: Map<string, string> = new Map();
19
+ export function setInlineEnumUnions(map: Map<string, string>): void {
20
+ inlineEnumUnions = map;
21
+ }
22
+ export function isInlineEnum(name: string): boolean {
23
+ return inlineEnumUnions.has(name);
24
+ }
25
+
26
+ /**
27
+ * Optional callback that resolves an IR model name to its live-SDK interface
28
+ * name. Set by `index.ts` once per run. When present, `mapTypeRef` and
29
+ * `mapWireTypeRef` use it instead of the raw IR name in their `model:` cases
30
+ * — keeping field-type references in sync with import statements that the
31
+ * caller emits via the same resolver. Without this, a structural match like
32
+ * IR `AuditLogSchemaJson` → live `AuditLogSchemaResponse` would produce
33
+ * `schema: AuditLogSchemaJson` in the body but
34
+ * `import type { AuditLogSchemaResponse }` in the imports, leaving
35
+ * `AuditLogSchemaJson` unbound.
36
+ */
37
+ let domainNameResolver: ((irName: string) => string) | null = null;
38
+ export function setDomainNameResolver(fn: ((irName: string) => string) | null): void {
39
+ domainNameResolver = fn;
40
+ }
41
+ function resolveDomainName(irName: string): string {
42
+ return domainNameResolver ? domainNameResolver(irName) : irName;
43
+ }
44
+
11
45
  /**
12
46
  * Map an IR TypeRef to a TypeScript domain type string.
13
47
  * Domain types use PascalCase model names (e.g., `Organization`).
14
- *
15
- * @param opts.genericDefaults - When present, appends default type args to generic model refs.
16
48
  */
17
49
  export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
18
50
  const genericDefaults = opts?.genericDefaults;
19
51
  return irMapTypeRef<string>(ref, {
20
52
  primitive: mapPrimitive,
21
53
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
22
- model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
23
- enum: (r) => r.name,
54
+ model: (r) => resolveDomainName(r.name) + (genericDefaults?.get(r.name) ?? ''),
55
+ enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
24
56
  union: (r, variants) => joinUnionVariants(r, variants),
25
57
  nullable: (_r, inner) => `${inner} | null`,
26
58
  literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
@@ -31,15 +63,14 @@ export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
31
63
  /**
32
64
  * Map an IR TypeRef to a TypeScript wire/response type string.
33
65
  * Model references get the `Response` suffix (e.g., `OrganizationResponse`).
34
- * Wire types use JSON-native types (string for date-time, number/string for int64).
35
66
  */
36
67
  export function mapWireTypeRef(ref: TypeRef, opts?: { genericDefaults?: Map<string, string> }): string {
37
68
  const genericDefaults = opts?.genericDefaults;
38
69
  return irMapTypeRef<string>(ref, {
39
70
  primitive: mapWirePrimitive,
40
71
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
41
- model: (r) => wireInterfaceName(r.name) + (genericDefaults?.get(r.name) ?? ''),
42
- enum: (r) => r.name,
72
+ model: (r) => wireInterfaceName(resolveDomainName(r.name)) + (genericDefaults?.get(r.name) ?? ''),
73
+ enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
43
74
  union: (r, variants) => joinUnionVariants(r, variants),
44
75
  nullable: (_r, inner) => `${inner} | null`,
45
76
  literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
@@ -69,10 +100,6 @@ function mapPrimitive(ref: PrimitiveType): string {
69
100
  }
70
101
  }
71
102
 
72
- /**
73
- * Map an IR PrimitiveType to a TypeScript wire/JSON type string.
74
- * Wire types match JSON encoding: date-time stays string, int64 stays string/number.
75
- */
76
103
  function mapWirePrimitive(ref: PrimitiveType): string {
77
104
  switch (ref.type) {
78
105
  case 'string':
@@ -87,10 +114,6 @@ function mapWirePrimitive(ref: PrimitiveType): string {
87
114
  }
88
115
  }
89
116
 
90
- /**
91
- * Join union variant type strings using the appropriate operator.
92
- * allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
93
- */
94
117
  function joinUnionVariants(ref: UnionType, variants: string[]): string {
95
118
  const unique = [...new Set(variants)];
96
119
  if (ref.compositionKind === 'allOf') {
@@ -100,7 +123,6 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
100
123
  return unique.join(' | ');
101
124
  }
102
125
 
103
- /** Wrap union/intersection types in parentheses when used as array item type. */
104
126
  function parenthesizeUnion(type: string): string {
105
127
  return type.includes(' | ') || type.includes(' & ') ? `(${type})` : type;
106
128
  }