@workos/oagen-emitters 0.17.0 → 0.18.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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BLnR-FMi.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DAa-HsN5.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -42,18 +42,18 @@
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^25.9.3",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.54.0",
46
- "oxlint": "^1.69.0",
45
+ "oxfmt": "^0.55.0",
46
+ "oxlint": "^1.70.0",
47
47
  "prettier": "^3.8.4",
48
- "tsdown": "^0.22.2",
48
+ "tsdown": "^0.22.3",
49
49
  "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.8"
51
+ "vitest": "^4.1.9"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.5"
57
+ "@workos/oagen": "^0.22.6"
58
58
  }
59
59
  }
@@ -50,13 +50,17 @@ interface FieldSpec {
50
50
  modelDeps: Set<string>;
51
51
  /** Whether the field requires date parsing/formatting (format: date-time). */
52
52
  hasDateTime: boolean;
53
+ /** Inline string-enum values, rendered as a literal union (e.g. `'a' | 'b'`). */
54
+ enumValues?: string[];
53
55
  }
54
56
 
55
57
  interface VariantSpec {
56
58
  /** Domain interface name suffix, e.g. `OAuth`, `M2M`. */
57
59
  nameSuffix: string;
58
- /** Discriminator string value, e.g. `oauth`, `m2m`. */
60
+ /** Discriminator value, e.g. `oauth`, `m2m`, or `true`/`false`. */
59
61
  discriminatorValue: string;
62
+ /** Whether the discriminator value is a boolean literal (emit unquoted). */
63
+ discriminatorIsBoolean?: boolean;
60
64
  /** Fields specific to this variant (excluding the discriminator). */
61
65
  fields: FieldSpec[];
62
66
  }
@@ -72,6 +76,13 @@ interface DiscriminatedShape {
72
76
  /** Description from the OpenAPI spec, if present. */
73
77
  discriminatorDescription?: string;
74
78
  variants: VariantSpec[];
79
+ /**
80
+ * Set for pure `oneOf` schemas (no `allOf` base wrapper). These are emitted
81
+ * as an inline anonymous union (`type X = { … } | { … }`) rather than as a
82
+ * set of named variant interfaces, which keeps two-variant unions — e.g. the
83
+ * boolean-discriminated `active: true | false` token response — readable.
84
+ */
85
+ inlineUnion?: boolean;
75
86
  }
76
87
 
77
88
  // ---------------------------------------------------------------------------
@@ -83,25 +94,45 @@ export function detectDiscriminatedShape(
83
94
  rawSchemas: Record<string, RawSchema>,
84
95
  ): DiscriminatedShape | null {
85
96
  const schema = rawSchemas[modelName];
86
- if (!schema?.allOf) return null;
97
+ if (!schema) return null;
87
98
 
88
- // The expected shape: allOf contains exactly one base object and one oneOf
89
- // wrapper. The base contributes shared fields; the oneOf contributes
90
- // variant-specific fields.
91
99
  let baseObject: RawSchema | null = null;
92
100
  let oneOfVariants: RawSchema[] | null = null;
93
- for (const member of schema.allOf) {
94
- const resolved = resolveRef(member, rawSchemas);
95
- if (resolved.oneOf) {
96
- if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
97
- oneOfVariants = resolved.oneOf;
98
- } else if (resolved.properties) {
99
- baseObject = mergeBase(baseObject, resolved);
100
- } else if (resolved.allOf) {
101
- // Nested allOf at top: walk it
102
- const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
103
- baseObject = mergeBase(baseObject, nestedBase);
101
+ let inlineUnion = false;
102
+
103
+ if (schema.allOf) {
104
+ // `allOf [base, oneOf [variant, …]]`: the base contributes shared fields;
105
+ // the oneOf contributes variant-specific fields. Emitted as named variant
106
+ // interfaces (one per variant) plus a union alias.
107
+ for (const member of schema.allOf) {
108
+ const resolved = resolveRef(member, rawSchemas);
109
+ if (resolved.oneOf) {
110
+ if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
111
+ oneOfVariants = resolved.oneOf;
112
+ } else if (resolved.properties) {
113
+ baseObject = mergeBase(baseObject, resolved);
114
+ } else if (resolved.allOf) {
115
+ // Nested allOf at top: walk it
116
+ const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
117
+ baseObject = mergeBase(baseObject, nestedBase);
118
+ }
104
119
  }
120
+ } else if (schema.oneOf && schema.oneOf.length >= 2) {
121
+ // Pure `oneOf` (no base wrapper): every branch must be a plain inline
122
+ // object so a shared const discriminator can tell them apart. This is the
123
+ // discriminated-union shape the parser would otherwise flatten into a
124
+ // single all-optional interface (e.g. the boolean-discriminated token
125
+ // response `{ active: true; … } | { active: false; … }`). The
126
+ // mutually-exclusive-field-group oneOf (no shared discriminator) is left
127
+ // alone by the `findSharedDiscriminator` check below.
128
+ const allInlineObjects = schema.oneOf.every(
129
+ (v) => v.properties && !v.$ref && (v.type === 'object' || !v.type) && !v.allOf && !v.oneOf,
130
+ );
131
+ if (!allInlineObjects) return null;
132
+ oneOfVariants = schema.oneOf;
133
+ inlineUnion = true;
134
+ } else {
135
+ return null;
105
136
  }
106
137
  if (!oneOfVariants || oneOfVariants.length < 2) return null;
107
138
 
@@ -120,12 +151,13 @@ export function detectDiscriminatedShape(
120
151
 
121
152
  // Build variant specs.
122
153
  const variants: VariantSpec[] = flattenedVariants
123
- .map((fv) => {
124
- const discValue = readConstString(fv.alwaysProperties.get(discProp));
125
- if (!discValue) return null;
154
+ .map((fv): VariantSpec | null => {
155
+ const discValue = readConst(fv.alwaysProperties.get(discProp));
156
+ if (discValue === null) return null;
126
157
  return {
127
- nameSuffix: variantNameSuffix(discValue),
128
- discriminatorValue: discValue,
158
+ nameSuffix: variantNameSuffix(String(discValue)),
159
+ discriminatorValue: String(discValue),
160
+ discriminatorIsBoolean: typeof discValue === 'boolean',
129
161
  fields: variantFields(fv, discProp, modelName),
130
162
  };
131
163
  })
@@ -144,6 +176,7 @@ export function detectDiscriminatedShape(
144
176
  discriminatorPropertyDomain: toCamelCase(discProp),
145
177
  discriminatorDescription,
146
178
  variants,
179
+ inlineUnion,
147
180
  };
148
181
  }
149
182
 
@@ -302,12 +335,12 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
302
335
  const values: string[] = [];
303
336
  for (const v of variants) {
304
337
  const schema = v.alwaysProperties.get(propName);
305
- const val = readConstString(schema);
338
+ const val = readConst(schema);
306
339
  if (val === null) {
307
340
  allConst = false;
308
341
  break;
309
342
  }
310
- values.push(val);
343
+ values.push(String(val));
311
344
  }
312
345
  if (allConst && new Set(values).size === values.length) {
313
346
  return propName;
@@ -316,11 +349,17 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
316
349
  return null;
317
350
  }
318
351
 
319
- function readConstString(schema: RawSchema | undefined | null): string | null {
352
+ /**
353
+ * Read a discriminator value pinned by `const` (or a single-value `enum`).
354
+ * Supports string and boolean literals — the latter drives the
355
+ * `active: true | false` style token response union.
356
+ */
357
+ function readConst(schema: RawSchema | undefined | null): string | boolean | null {
320
358
  if (!schema) return null;
321
- if (typeof schema.const === 'string') return schema.const;
322
- if (Array.isArray(schema.enum) && schema.enum.length === 1 && typeof schema.enum[0] === 'string') {
323
- return schema.enum[0];
359
+ if (typeof schema.const === 'string' || typeof schema.const === 'boolean') return schema.const;
360
+ if (Array.isArray(schema.enum) && schema.enum.length === 1) {
361
+ const only = schema.enum[0];
362
+ if (typeof only === 'string' || typeof only === 'boolean') return only;
324
363
  }
325
364
  return null;
326
365
  }
@@ -360,6 +399,10 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
360
399
  const modelDeps = new Set<string>();
361
400
  const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps);
362
401
  const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps);
402
+ const enumValues =
403
+ Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((e) => typeof e === 'string')
404
+ ? (schema.enum as string[])
405
+ : undefined;
363
406
  return {
364
407
  name: rawName,
365
408
  description: schema.description,
@@ -368,6 +411,7 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
368
411
  wireType,
369
412
  modelDeps,
370
413
  hasDateTime: schemaHasDateTime(schema),
414
+ enumValues,
371
415
  };
372
416
  }
373
417
 
@@ -487,6 +531,15 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
487
531
  depDirMap.set(rawName, irModelDir.get(stripped)!);
488
532
  }
489
533
  }
534
+ // Synthetic IR models — e.g. inline-object variant fields like the token
535
+ // response's nested `access_token` — have PascalCase names that never appear
536
+ // in rawSchemas, so the loop above misses them. Seed their directories from
537
+ // irModelDir so `collectImports` (which only receives `depDirMap` via the
538
+ // plan) can resolve a cross-service inline-object dep instead of defaulting
539
+ // to a possibly-wrong same-dir import.
540
+ for (const [irName, dir] of irModelDir) {
541
+ if (!depDirMap.has(irName)) depDirMap.set(irName, dir);
542
+ }
490
543
 
491
544
  for (const model of models) {
492
545
  const shape = detectDiscriminatedShape(model.name, rawSchemas);
@@ -503,7 +556,17 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
503
556
  for (const d of field.modelDeps) allDeps.add(d);
504
557
  }
505
558
  }
506
- const hasUnresolvableDeps = [...allDeps].some((dep) => !depDirMap.has(dep) && !irModelDir.has(dep));
559
+ // `modelDeps` may carry an inline-object synthetic name in raw form
560
+ // (`Parent_field`) while the resolution maps are keyed by the PascalCase IR
561
+ // model name (`ParentField`). Resolve either spelling before deciding a dep
562
+ // is unreachable — otherwise inline-object variant fields (e.g. the token
563
+ // response's nested `access_token`) would wrongly drop the whole plan.
564
+ const resolvable = (dep: string): boolean =>
565
+ depDirMap.has(dep) ||
566
+ irModelDir.has(dep) ||
567
+ depDirMap.has(toPascalCase(dep)) ||
568
+ irModelDir.has(toPascalCase(dep));
569
+ const hasUnresolvableDeps = [...allDeps].some((dep) => !resolvable(dep));
507
570
  if (hasUnresolvableDeps) continue;
508
571
  const modelDir = resolveDir(modelToService.get(model.name));
509
572
  plans.set(model.name, { shape, modelDir, depDirMap });
@@ -525,6 +588,7 @@ export function generateDiscriminatedFiles(
525
588
 
526
589
  function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
527
590
  const { shape, modelDir } = plan;
591
+ if (shape.inlineUnion) return buildInlineUnionFile(plan);
528
592
  const domain = toPascalCase(shape.modelName);
529
593
  const wire = wireInterfaceName(domain);
530
594
  const lines: string[] = [];
@@ -561,6 +625,53 @@ function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gene
561
625
  };
562
626
  }
563
627
 
628
+ /**
629
+ * Emit a pure-`oneOf` discriminated union as a single inline type alias
630
+ * (`export type X = { … } | { … }`) for both the domain and wire shapes. Used
631
+ * instead of named per-variant interfaces, which read poorly for small
632
+ * (often two-variant, boolean-discriminated) unions like the token response.
633
+ */
634
+ function buildInlineUnionFile(plan: DiscriminatedPlan): GeneratedFile {
635
+ const { shape, modelDir } = plan;
636
+ const domain = toPascalCase(shape.modelName);
637
+ const wire = wireInterfaceName(domain);
638
+ const lines: string[] = [];
639
+
640
+ const imports = collectImports(plan);
641
+ if (imports.length > 0) {
642
+ for (const imp of imports) {
643
+ lines.push(`import type { ${imp.symbols.join(', ')} } from '${imp.path}';`);
644
+ }
645
+ lines.push('');
646
+ }
647
+
648
+ lines.push(...buildInlineUnionAlias(domain, shape, /*isWire*/ false));
649
+ lines.push('');
650
+ lines.push(...buildInlineUnionAlias(wire, shape, /*isWire*/ true));
651
+
652
+ return {
653
+ path: `src/${modelDir}/interfaces/${fileName(shape.modelName)}.interface.ts`,
654
+ content: lines.join('\n') + '\n',
655
+ overwriteExisting: true,
656
+ };
657
+ }
658
+
659
+ function buildInlineUnionAlias(name: string, shape: DiscriminatedShape, isWire: boolean): string[] {
660
+ const lines: string[] = [`export type ${name} =`];
661
+ shape.variants.forEach((variant, idx) => {
662
+ const isLast = idx === shape.variants.length - 1;
663
+ const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
664
+ const members = [`${discKey}: ${discLiteral(variant)}`];
665
+ for (const field of variant.fields) {
666
+ const key = isWire ? field.name : toCamelCase(field.name);
667
+ const opt = field.required ? '' : '?';
668
+ members.push(`${key}${opt}: ${inlineFieldType(field, isWire)}`);
669
+ }
670
+ lines.push(` | { ${members.join('; ')} }${isLast ? ';' : ''}`);
671
+ });
672
+ return lines;
673
+ }
674
+
564
675
  function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
565
676
  const lines: string[] = [];
566
677
  lines.push(`export interface ${name} {`);
@@ -578,7 +689,7 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
578
689
  if (shape.discriminatorDescription) {
579
690
  lines.push(` /** ${shape.discriminatorDescription} */`);
580
691
  }
581
- lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
692
+ lines.push(` ${discKey}: ${discLiteral(variant)};`);
582
693
  // Variant-specific fields
583
694
  for (const field of variant.fields) {
584
695
  pushFieldLine(lines, field, isWire);
@@ -587,6 +698,31 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
587
698
  return lines;
588
699
  }
589
700
 
701
+ /**
702
+ * The discriminator value as a TS literal: quoted for strings (`'oauth'`),
703
+ * bare for booleans (`true`).
704
+ */
705
+ function discLiteral(variant: VariantSpec): string {
706
+ return variant.discriminatorIsBoolean ? variant.discriminatorValue : `'${variant.discriminatorValue}'`;
707
+ }
708
+
709
+ /**
710
+ * Field type for an inline-union member: a literal union for inline string
711
+ * enums, otherwise the resolved domain/wire type.
712
+ *
713
+ * The `enumValues` branch deliberately ignores `isWire`: `enumValues` is only
714
+ * populated for string enums (see `buildField`), whose domain and wire
715
+ * representations are identical literal unions. Any field whose domain/wire
716
+ * types actually differ (model refs, dates, non-string enums) leaves
717
+ * `enumValues` undefined and falls through to the type-specific branch.
718
+ */
719
+ function inlineFieldType(field: FieldSpec, isWire: boolean): string {
720
+ if (field.enumValues) {
721
+ return field.enumValues.map((v) => `'${v}'`).join(' | ');
722
+ }
723
+ return isWire ? field.wireType : field.domainType;
724
+ }
725
+
590
726
  function pushFieldLine(lines: string[], field: FieldSpec, isWire: boolean): void {
591
727
  const key = isWire ? field.name : toCamelCase(field.name);
592
728
  const opt = field.required ? '' : '?';
@@ -617,7 +753,10 @@ function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
617
753
  const domain = toPascalCase(dep);
618
754
  const wire = wireInterfaceName(domain);
619
755
  const symbols = wire !== domain ? [domain, wire] : [domain];
620
- const depDir = plan.depDirMap.get(dep);
756
+ // `dep` may be a raw spec name or a synthetic inline-object name in snake
757
+ // form (`Parent_field`); `depDirMap` keys the latter under its PascalCase IR
758
+ // name, so fall back to that spelling before defaulting to a same-dir path.
759
+ const depDir = plan.depDirMap.get(dep) ?? plan.depDirMap.get(domain);
621
760
  const baseName = fileName(toSnakeFromPascal(domain));
622
761
  let importPath: string;
623
762
  if (!depDir || depDir === plan.modelDir) {
@@ -660,10 +799,16 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
660
799
  for (const variant of shape.variants)
661
800
  for (const field of variant.fields) for (const d of field.modelDeps) allDeps.add(d);
662
801
 
802
+ // Pure-`oneOf` unions are response shapes (e.g. the token response): they are
803
+ // only ever deserialized, and their inline-object fields may have no
804
+ // serializer generated. Emit a deserializer only and import just the
805
+ // deserialize helpers — mirrors the published hand-written serializer.
806
+ const deserializeOnly = shape.inlineUnion === true;
663
807
  for (const dep of [...allDeps].sort()) {
664
808
  const depDomain = toPascalCase(dep);
665
809
  const depFile = fileName(toSnakeFromPascal(depDomain));
666
- lines.push(`import { deserialize${depDomain}, serialize${depDomain} } from './${depFile}.serializer';`);
810
+ const helpers = deserializeOnly ? `deserialize${depDomain}` : `deserialize${depDomain}, serialize${depDomain}`;
811
+ lines.push(`import { ${helpers} } from './${depFile}.serializer';`);
667
812
  }
668
813
  lines.push('');
669
814
 
@@ -671,12 +816,12 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
671
816
  lines.push(`export const deserialize${domain} = (response: ${wire}): ${domain} => {`);
672
817
  lines.push(` switch (response.${shape.discriminatorProperty}) {`);
673
818
  for (const variant of shape.variants) {
674
- lines.push(` case '${variant.discriminatorValue}':`);
819
+ lines.push(` case ${discLiteral(variant)}:`);
675
820
  lines.push(` return {`);
676
821
  for (const field of shape.baseFields) {
677
822
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
678
823
  }
679
- lines.push(` ${shape.discriminatorPropertyDomain}: '${variant.discriminatorValue}',`);
824
+ lines.push(` ${shape.discriminatorPropertyDomain}: ${discLiteral(variant)},`);
680
825
  for (const field of variant.fields) {
681
826
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
682
827
  }
@@ -684,29 +829,31 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
684
829
  }
685
830
  lines.push(` default:`);
686
831
  lines.push(
687
- ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${(response as { ${shape.discriminatorProperty}: string }).${shape.discriminatorProperty}}\`);`,
832
+ ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${String((response as Record<string, unknown>).${shape.discriminatorProperty})}\`);`,
688
833
  );
689
834
  lines.push(` }`);
690
835
  lines.push(`};`);
691
- lines.push('');
692
836
 
693
- // Serializer
694
- lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
695
- lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
696
- for (const variant of shape.variants) {
697
- lines.push(` case '${variant.discriminatorValue}':`);
698
- lines.push(` return {`);
699
- for (const field of shape.baseFields) {
700
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
701
- }
702
- lines.push(` ${shape.discriminatorProperty}: '${variant.discriminatorValue}',`);
703
- for (const field of variant.fields) {
704
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
837
+ if (!deserializeOnly) {
838
+ lines.push('');
839
+ // Serializer
840
+ lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
841
+ lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
842
+ for (const variant of shape.variants) {
843
+ lines.push(` case ${discLiteral(variant)}:`);
844
+ lines.push(` return {`);
845
+ for (const field of shape.baseFields) {
846
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
847
+ }
848
+ lines.push(` ${shape.discriminatorProperty}: ${discLiteral(variant)},`);
849
+ for (const field of variant.fields) {
850
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
851
+ }
852
+ lines.push(` };`);
705
853
  }
706
- lines.push(` };`);
854
+ lines.push(` }`);
855
+ lines.push(`};`);
707
856
  }
708
- lines.push(` }`);
709
- lines.push(`};`);
710
857
 
711
858
  return {
712
859
  path: `src/${modelDir}/serializers/${fileName(shape.modelName)}.serializer.ts`,
@@ -327,6 +327,18 @@ function isValidTypeIdentifier(name: string): boolean {
327
327
  return /^[A-Za-z_$][\w$]*$/.test(name);
328
328
  }
329
329
 
330
+ /**
331
+ * Extract candidate named type references from a compound type expression such
332
+ * as `GetAccessTokenOptions & { provider: string }`. PascalCase tokens are type
333
+ * names worth importing; lowercase tokens are property keys or primitives and
334
+ * are skipped. The caller only imports the ones that resolve to a known source
335
+ * file, so unrecognized PascalCase tokens (e.g. `Date`, `Record`) are harmless.
336
+ */
337
+ function extractNamedTypeRefs(typeExpr: string): string[] {
338
+ const matches = typeExpr.match(/\b[A-Z][A-Za-z0-9_$]*\b/g) ?? [];
339
+ return [...new Set(matches)];
340
+ }
341
+
330
342
  function autoPaginatableItemType(returnType: string | undefined): string | undefined {
331
343
  // Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
332
344
  // item types are extracted even when the hand-written code predates AutoPaginatable.
@@ -964,16 +976,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
964
976
 
965
977
  const importedTypeNames = new Set<string>();
966
978
  for (const optionType of optionObjectTypes) {
967
- // Inline object-literal types from the baseline surface are rendered
968
- // inline in the method signature — they have no importable name or file.
969
- if (!isValidTypeIdentifier(optionType)) continue;
970
- if (importedTypeNames.has(optionType)) continue;
971
- importedTypeNames.add(optionType);
972
- const sourceFile = baselineTypeSourceFile(ctx, optionType);
973
- const relPath = sourceFile
974
- ? relativeImport(resourcePath, sourceFile)
975
- : `./interfaces/${fileName(optionType)}.interface`;
976
- lines.push(`import type { ${optionType} } from '${relPath}';`);
979
+ if (isValidTypeIdentifier(optionType)) {
980
+ if (importedTypeNames.has(optionType)) continue;
981
+ importedTypeNames.add(optionType);
982
+ const sourceFile = baselineTypeSourceFile(ctx, optionType);
983
+ const relPath = sourceFile
984
+ ? relativeImport(resourcePath, sourceFile)
985
+ : `./interfaces/${fileName(optionType)}.interface`;
986
+ lines.push(`import type { ${optionType} } from '${relPath}';`);
987
+ continue;
988
+ }
989
+ // Compound option types (e.g. a baseline `GetAccessTokenOptions & { provider:
990
+ // string }`) keep their inline object literal inline, but the named type(s)
991
+ // they reference must still be imported. Only import names that resolve to a
992
+ // known source file — inline literals, primitives, and property keys have no
993
+ // importable source and are skipped.
994
+ for (const typeName of extractNamedTypeRefs(optionType)) {
995
+ if (importedTypeNames.has(typeName)) continue;
996
+ const sourceFile = baselineTypeSourceFile(ctx, typeName) ?? liveSurfaceInterfacePath(typeName);
997
+ if (!sourceFile) continue;
998
+ importedTypeNames.add(typeName);
999
+ lines.push(`import type { ${typeName} } from '${relativeImport(resourcePath, sourceFile)}';`);
1000
+ }
977
1001
  }
978
1002
  for (const typeName of returnTypeImports) {
979
1003
  if (importedTypeNames.has(typeName)) continue;
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import {
5
+ detectDiscriminatedShape,
6
+ generateDiscriminatedFiles,
7
+ type DiscriminatedPlan,
8
+ } from '../../src/node/discriminated-models.js';
9
+
10
+ const emptySpec: ApiSpec = {
11
+ name: 'Test',
12
+ version: '1.0.0',
13
+ baseUrl: '',
14
+ services: [],
15
+ models: [],
16
+ enums: [],
17
+ sdk: defaultSdkBehavior(),
18
+ };
19
+
20
+ const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
21
+
22
+ // A pure `oneOf` discriminated by a boolean `active` const — the Pipes token
23
+ // response shape that previously collapsed into a flat all-optional interface.
24
+ // `RawSchema` is internal to the emitter; the loose typing mirrors the raw
25
+ // `components.schemas` shape `detectDiscriminatedShape` consumes at runtime.
26
+ const rawSchemas: Record<string, any> = {
27
+ DataIntegrationAccessTokenResponse: {
28
+ oneOf: [
29
+ {
30
+ type: 'object',
31
+ properties: {
32
+ active: { type: 'boolean', const: true },
33
+ access_token: {
34
+ type: 'object',
35
+ properties: {
36
+ object: { type: 'string', const: 'access_token' },
37
+ access_token: { type: 'string' },
38
+ expires_at: { type: ['string', 'null'], format: 'date-time' },
39
+ scopes: { type: 'array', items: { type: 'string' } },
40
+ missing_scopes: { type: 'array', items: { type: 'string' } },
41
+ },
42
+ required: ['object', 'access_token', 'expires_at', 'scopes', 'missing_scopes'],
43
+ },
44
+ },
45
+ required: ['active', 'access_token'],
46
+ },
47
+ {
48
+ type: 'object',
49
+ properties: {
50
+ active: { type: 'boolean', const: false },
51
+ error: { type: 'string', enum: ['not_installed', 'needs_reauthorization'] },
52
+ },
53
+ required: ['active', 'error'],
54
+ },
55
+ ],
56
+ },
57
+ };
58
+
59
+ describe('detectDiscriminatedShape — pure oneOf with boolean discriminator', () => {
60
+ it('detects a two-variant inline union keyed on the boolean `active`', () => {
61
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas);
62
+ expect(shape).not.toBeNull();
63
+ expect(shape!.inlineUnion).toBe(true);
64
+ expect(shape!.discriminatorProperty).toBe('active');
65
+ expect(shape!.variants).toHaveLength(2);
66
+ expect(shape!.variants.map((v) => v.discriminatorValue).sort()).toEqual(['false', 'true']);
67
+ expect(shape!.variants.every((v) => v.discriminatorIsBoolean)).toBe(true);
68
+ });
69
+
70
+ it('emits a discriminated union interface (not a flat optional bag)', () => {
71
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
72
+ const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap: new Map() };
73
+ const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
74
+
75
+ const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
76
+ expect(iface).toBeDefined();
77
+ // Union alias, two variants, boolean discriminator (unquoted), required fields.
78
+ expect(iface.content).toContain('export type DataIntegrationAccessTokenResponse =');
79
+ expect(iface.content).toContain('active: true');
80
+ expect(iface.content).toContain('active: false');
81
+ expect(iface.content).toContain('accessToken: DataIntegrationAccessTokenResponseAccessToken');
82
+ expect(iface.content).toContain("error: 'not_installed' | 'needs_reauthorization'");
83
+ // No optional discriminator — narrowing must work.
84
+ expect(iface.content).not.toContain('active?: true');
85
+
86
+ const ser = files.find((f) => f.path.endsWith('.serializer.ts'))!;
87
+ expect(ser.content).toContain('switch (response.active)');
88
+ expect(ser.content).toContain('case true:');
89
+ expect(ser.content).toContain('case false:');
90
+ });
91
+
92
+ it('resolves a cross-service inline-object dep to a relative import path', () => {
93
+ // The nested `access_token` object is a synthetic IR model. Its dep is
94
+ // carried in snake form (`Parent_field`) but keyed in depDirMap under the
95
+ // PascalCase IR name. When that model resolves to a different service dir,
96
+ // collectImports must emit a cross-service path rather than defaulting to
97
+ // a same-dir import.
98
+ const shape = detectDiscriminatedShape('DataIntegrationAccessTokenResponse', rawSchemas)!;
99
+ const depDirMap = new Map<string, string>([['DataIntegrationAccessTokenResponseAccessToken', 'connect']]);
100
+ const plan: DiscriminatedPlan = { shape, modelDir: 'pipes', depDirMap };
101
+ const files = generateDiscriminatedFiles(new Map([['DataIntegrationAccessTokenResponse', plan]]), ctx);
102
+
103
+ const iface = files.find((f) => f.path.endsWith('.interface.ts'))!;
104
+ expect(iface.content).toContain(
105
+ "from '../../connect/interfaces/data-integration-access-token-response-access-token.interface'",
106
+ );
107
+ });
108
+ });