@workos/oagen-emitters 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-DRGwxN88.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-D0qLBiGv.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -1,6 +1,7 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
  import { fileName, fieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
4
5
 
5
6
  /**
6
7
  * Prefix mapping for generating realistic ID fixture values.
@@ -34,9 +35,12 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
34
35
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
35
36
  const files: { path: string; content: string }[] = [];
36
37
 
38
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
39
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
40
+
37
41
  for (const model of spec.models) {
38
- if (isListMetadataModel(model)) continue;
39
- if (isListWrapperModel(model)) continue;
42
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
43
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
40
44
 
41
45
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
42
46
 
@@ -142,6 +146,22 @@ export function generateModelFixture(
142
146
  }
143
147
  }
144
148
 
149
+ if (model.discriminator) {
150
+ const [firstValue, variantName] = Object.entries(model.discriminator.mapping)[0];
151
+ fixture[model.discriminator.property] = firstValue;
152
+ const variantModel = modelMap.get(variantName);
153
+ if (variantModel) {
154
+ for (const field of variantModel.fields) {
155
+ if (!(field.name in fixture)) {
156
+ fixture[field.name] =
157
+ field.example !== undefined
158
+ ? field.example
159
+ : generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
160
+ }
161
+ }
162
+ }
163
+ }
164
+
145
165
  return fixture;
146
166
  }
147
167
 
package/src/go/models.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  isListWrapperModel,
10
10
  isListMetadataModel,
11
11
  collectNonPaginatedResponseModelNames,
12
+ collectReferencedListMetadataModels,
12
13
  } from '../shared/model-utils.js';
13
14
  export { isListWrapperModel, isListMetadataModel };
14
15
 
@@ -92,12 +93,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
92
93
  // code references them by name and the pagination iterator doesn't unwrap them.
93
94
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
94
95
  const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
96
+ // A `ListMetadata`-shape model referenced by a surviving non-paginated
97
+ // wrapper (e.g. vault's `VersionListResponse`) must still be emitted —
98
+ // otherwise the wrapper's struct references a type that was never declared.
99
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
100
+ const skipAsListMetadata = (m: Model): boolean => isListMetadataModel(m) && !listMetadataNeeded.has(m.name);
95
101
 
96
102
  // Build structural hash for deduplication
97
103
  const modelHashMap = new Map<string, string>();
98
104
  const hashGroups = new Map<string, string[]>();
99
105
  for (const model of models) {
100
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
106
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
101
107
  if (requestBodyOnly.has(model.name)) continue;
102
108
  const hash = structuralHash(model);
103
109
  modelHashMap.set(model.name, hash);
@@ -121,7 +127,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
121
127
 
122
128
  const batchedAliases = new Set<string>();
123
129
  for (const model of models) {
124
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
130
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
125
131
  if (requestBodyOnly.has(model.name)) continue;
126
132
 
127
133
  const structName = className(model.name);
@@ -6,6 +6,7 @@ import {
6
6
  isListWrapperModel,
7
7
  isListMetadataModel,
8
8
  collectNonPaginatedResponseModelNames,
9
+ collectReferencedListMetadataModels,
9
10
  } from '../shared/model-utils.js';
10
11
 
11
12
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
@@ -67,13 +68,18 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
67
68
  // code references them by name and pagination iterators don't unwrap them.
68
69
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
69
70
  const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
71
+ // A `ListMetadata`-shape model referenced by a surviving non-paginated
72
+ // wrapper (e.g. vault's `VersionListResponse`) must still emit a data class
73
+ // — otherwise the wrapper's class references a type that was never declared.
74
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
75
+ const skipAsListMetadata = (m: Model): boolean => isListMetadataModel(m) && !listMetadataNeeded.has(m.name);
70
76
 
71
77
  // Deduplication: identical structures become typealiases.
72
78
  // Pass 1: hash without nested-alias resolution.
73
79
  modelAliasMap = null;
74
80
  const hashGroupsPass1 = new Map<string, string[]>();
75
81
  for (const model of models) {
76
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
82
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
77
83
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
78
84
  const hash = structuralHash(model);
79
85
  if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
@@ -96,7 +102,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
96
102
  modelAliasMap = aliasOf;
97
103
  const hashGroupsPass2 = new Map<string, string[]>();
98
104
  for (const model of models) {
99
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
105
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
100
106
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
101
107
  if (aliasOf.has(model.name)) continue; // already aliased in pass 1
102
108
  const hash = structuralHash(model);
@@ -115,7 +121,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
115
121
  }
116
122
 
117
123
  for (const model of models) {
118
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
124
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
119
125
  const typeName = className(model.name);
120
126
 
121
127
  // Parent of a discriminated union: emit a sealed class.
@@ -152,7 +158,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
152
158
  // mapping so Jackson can deserialize directly to the correct concrete type.
153
159
  const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
154
160
  for (const model of models) {
155
- if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
161
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
156
162
  if (aliasOf.has(model.name)) continue;
157
163
  if (!isEventEnvelopeModel(model)) continue;
158
164
  const eventField = model.fields.find((f) => f.name === 'event');
@@ -11,7 +11,12 @@ import type {
11
11
  } from '@workos/oagen';
12
12
  import { planOperation } from '@workos/oagen';
13
13
  import { mapTypeRef, mapTypeRefOptional, implicitImportsFor } from './type-map.js';
14
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
14
+ import {
15
+ isListWrapperModel,
16
+ isListMetadataModel,
17
+ collectNonPaginatedResponseModelNames,
18
+ collectReferencedListMetadataModels,
19
+ } from '../shared/model-utils.js';
15
20
  import { enumCanonicalMap } from './enums.js';
16
21
  import {
17
22
  className,
@@ -1124,6 +1129,12 @@ function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterCon
1124
1129
  const mapped = mapTypeRef(ref);
1125
1130
  for (const imp of implicitImportsFor(mapped)) imports.add(imp);
1126
1131
 
1132
+ // Match the model-emission gate: a `ListMetadata`-shape model that survives
1133
+ // emission (because a non-paginated wrapper still references it) needs its
1134
+ // import here too.
1135
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
1136
+ const listMetadataNeeded = collectReferencedListMetadataModels(ctx.spec.models, nonPaginatedRefs);
1137
+
1127
1138
  walk(ref, (r) => {
1128
1139
  if (r.kind === 'enum') {
1129
1140
  // When an enum is aliased, import the canonical class instead of the alias.
@@ -1132,7 +1143,11 @@ function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterCon
1132
1143
  }
1133
1144
  if (r.kind === 'model') {
1134
1145
  const referenced = ctx.spec.models.find((m) => m.name === r.name);
1135
- if (referenced && (isListWrapperModel(referenced) || isListMetadataModel(referenced))) return;
1146
+ if (referenced) {
1147
+ const skipWrapper = isListWrapperModel(referenced) && !nonPaginatedRefs.has(referenced.name);
1148
+ const skipMetadata = isListMetadataModel(referenced) && !listMetadataNeeded.has(referenced.name);
1149
+ if (skipWrapper || skipMetadata) return;
1150
+ }
1136
1151
  imports.add(`com.workos.models.${className(r.name)}`);
1137
1152
  }
1138
1153
  });
@@ -1,7 +1,14 @@
1
1
  import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { wireFieldName, fileName, resolveServiceDir } from './naming.js';
3
3
  import { resolveResourceClassName, resolveResourceDir } from './resources.js';
4
- import { createServiceDirResolver, assignModelsToServices, isListMetadataModel, isListWrapperModel } from './utils.js';
4
+ import {
5
+ createServiceDirResolver,
6
+ assignModelsToServices,
7
+ isListMetadataModel,
8
+ isListWrapperModel,
9
+ collectNonPaginatedResponseModelNames,
10
+ collectReferencedListMetadataModels,
11
+ } from './utils.js';
5
12
 
6
13
  export const ID_PREFIXES: Record<string, string> = {
7
14
  Connection: 'conn_',
@@ -75,11 +82,14 @@ export function generateFixtures(
75
82
  }
76
83
  }
77
84
 
85
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
86
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
87
+
78
88
  const seenFixturePaths = new Set<string>();
79
89
  for (const model of spec.models) {
80
90
  if (!fixtureReachable.has(model.name)) continue;
81
- if (isListMetadataModel(model)) continue;
82
- if (isListWrapperModel(model)) continue;
91
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
92
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
83
93
 
84
94
  const service = modelToService.get(model.name);
85
95
  const dirName = resolveDir(service);
@@ -155,6 +165,23 @@ export function generateModelFixture(
155
165
  }
156
166
  }
157
167
 
168
+ if (model.discriminator) {
169
+ const [firstValue, variantName] = Object.entries(model.discriminator.mapping)[0];
170
+ fixture[wireFieldName(model.discriminator.property)] = firstValue;
171
+ const variantModel = modelMap.get(variantName);
172
+ if (variantModel) {
173
+ for (const field of variantModel.fields) {
174
+ const wireName = wireFieldName(field.name);
175
+ if (!(wireName in fixture)) {
176
+ fixture[wireName] =
177
+ field.example !== undefined
178
+ ? field.example
179
+ : generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
158
185
  return fixture;
159
186
  }
160
187
 
package/src/node/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  setBaselineSerializedNames,
24
24
  setBaselineInterfaceNames,
25
25
  setAdoptedModelNames,
26
+ setStructurallyRenamedDomainNames,
26
27
  resolveInterfaceName,
27
28
  } from './naming.js';
28
29
  import { withNodeOperationOverrides } from './node-overrides.js';
@@ -108,6 +109,19 @@ function getSurface(ctx: EmitterContext): LiveSurface {
108
109
  // Pass an empty map; type-map will fall back to emitting the symbol name.
109
110
  setInlineEnumUnions(new Map());
110
111
  setAdoptedModelNames(computeAdoptedModelNames(ctx, surface));
112
+
113
+ // Pre-compute which domain names the resolver reaches via structural
114
+ // rename so `wireInterfaceName` can tell `AuditLogSchemaJson` →
115
+ // `AuditLogSchemaResponse` (real single-form case) apart from
116
+ // `CreateDataKeyResponse` → `CreateDataKeyResponse` (fresh IR model whose
117
+ // own name already ends in `Response`).
118
+ const renamed = new Set<string>();
119
+ for (const model of ctx.spec.models) {
120
+ const resolved = resolveInterfaceName(model.name, ctx);
121
+ if (resolved !== model.name) renamed.add(resolved);
122
+ }
123
+ setStructurallyRenamedDomainNames(renamed);
124
+
111
125
  return surface;
112
126
  }
113
127
 
@@ -181,6 +195,11 @@ function markPriorManifestAutogen(
181
195
  if (!surface.files.has(relPath)) continue;
182
196
  if (surface.protectedFiles.has(relPath)) continue;
183
197
 
198
+ if (/\/fixtures\/[^/]+\.json$/.test(relPath)) {
199
+ surface.autogenFiles.add(relPath);
200
+ continue;
201
+ }
202
+
184
203
  try {
185
204
  const text = fs.readFileSync(path.join(root, relPath), 'utf8');
186
205
  if (/auto-generated by oagen/i.test(text.slice(0, 400))) {
@@ -24,6 +24,7 @@ import {
24
24
  isListMetadataModel,
25
25
  isListWrapperModel,
26
26
  collectNonPaginatedResponseModelNames,
27
+ collectReferencedListMetadataModels,
27
28
  buildDeduplicationMap,
28
29
  relativeImport,
29
30
  modelHasNewFields,
@@ -214,11 +215,19 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
214
215
  // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
215
216
  // code references them by name and pagination iterators don't unwrap them.
216
217
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
218
+
219
+ // ListMetadata-shape models are usually subsumed by the SDK's shared
220
+ // pagination wrapper, so we blanket-skip them. But a non-paginated
221
+ // wrapper like vault's `VersionListResponse` keeps the reference live
222
+ // (`list_metadata: ListMetadata`), and skipping the emission leaves the
223
+ // wrapper's interface importing from a file that was never written.
224
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
225
+
217
226
  for (const originalModel of models) {
218
227
  const model = projectedByName.get(originalModel.name) ?? originalModel;
219
228
  if (!reachableModels.has(model.name)) continue;
220
229
  if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
221
- if (isListMetadataModel(model)) continue;
230
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
222
231
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
223
232
  if (discriminatedSkip?.has(model.name)) continue;
224
233
  const service = modelToService.get(model.name);
@@ -517,36 +526,43 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
517
526
  }
518
527
  lines.push('');
519
528
 
520
- // Wire/response interface
521
- const seenWireFields = new Set<string>();
522
- if (model.fields.length === 0) {
523
- lines.push(`export type ${responseName}${typeParams} = object;`);
524
- } else {
525
- lines.push(`export interface ${responseName}${typeParams} {`);
526
- for (const field of model.fields) {
527
- const wireField = wireFieldName(field.name);
528
- if (seenWireFields.has(wireField)) continue;
529
- seenWireFields.add(wireField);
530
- const baselineField = baselineResponse?.fields?.[wireField];
531
- if (
532
- baselineField &&
533
- baselineTypeResolvable(baselineField.type, importableNames) &&
534
- baselineFieldCompatible(baselineField, field)
535
- ) {
536
- const opt = baselineField.optional ? '?' : '';
537
- lines.push(` ${wireField}${opt}: ${baselineField.type};`);
538
- } else {
539
- const isNewFieldOnExistingModel = baselineResponse && !baselineField;
540
- // Same baseline-optional preservation as the domain side. The
541
- // wire interface's optional flag drives test-fixture shape, so
542
- // flipping it on regen breaks every fixture that omitted the
543
- // field assuming it was optional.
544
- const baselineSaysOptional = baselineField?.optional === true;
545
- const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
546
- lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
529
+ // Wire/response interface — skip when the wire name collapsed onto the
530
+ // domain name (single-form structural-rename case, e.g. IR `Object` →
531
+ // `ReadObjectResponse`). Emitting the second declaration would either
532
+ // produce a literal duplicate `export interface ReadObjectResponse`
533
+ // pair or, after TypeScript's silent declaration merge, leave the
534
+ // call site with `import type { ReadObjectResponse, ReadObjectResponse }`.
535
+ if (responseName !== domainName) {
536
+ const seenWireFields = new Set<string>();
537
+ if (model.fields.length === 0) {
538
+ lines.push(`export type ${responseName}${typeParams} = object;`);
539
+ } else {
540
+ lines.push(`export interface ${responseName}${typeParams} {`);
541
+ for (const field of model.fields) {
542
+ const wireField = wireFieldName(field.name);
543
+ if (seenWireFields.has(wireField)) continue;
544
+ seenWireFields.add(wireField);
545
+ const baselineField = baselineResponse?.fields?.[wireField];
546
+ if (
547
+ baselineField &&
548
+ baselineTypeResolvable(baselineField.type, importableNames) &&
549
+ baselineFieldCompatible(baselineField, field)
550
+ ) {
551
+ const opt = baselineField.optional ? '?' : '';
552
+ lines.push(` ${wireField}${opt}: ${baselineField.type};`);
553
+ } else {
554
+ const isNewFieldOnExistingModel = baselineResponse && !baselineField;
555
+ // Same baseline-optional preservation as the domain side. The
556
+ // wire interface's optional flag drives test-fixture shape, so
557
+ // flipping it on regen breaks every fixture that omitted the
558
+ // field assuming it was optional.
559
+ const baselineSaysOptional = baselineField?.optional === true;
560
+ const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
561
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
562
+ }
547
563
  }
564
+ lines.push('}');
548
565
  }
549
- lines.push('}');
550
566
  }
551
567
 
552
568
  // Preserve inline types from existing file
@@ -739,12 +755,16 @@ export function generateSerializers(
739
755
 
740
756
  const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
741
757
  const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
758
+
759
+ // Mirror the interface-emission gate (see `generateModels`).
760
+ const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
761
+
742
762
  const eligibleModels: Model[] = [];
743
763
  for (const originalModel of models) {
744
764
  const model = projectedByName.get(originalModel.name) ?? originalModel;
745
765
  if (!serializerReachable.has(model.name)) continue;
746
766
  if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
747
- if (isListMetadataModel(model)) continue;
767
+ if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
748
768
  if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
749
769
  if (discriminatedSerializerSkip?.has(model.name)) continue;
750
770
  const service = modelToService.get(model.name);
@@ -73,6 +73,23 @@ export function isAdoptedModelName(name: string): boolean {
73
73
  return adoptedModelNames.has(name);
74
74
  }
75
75
 
76
+ /**
77
+ * Domain names that `resolveInterfaceName` reached via a structural rename
78
+ * — the resolved name differs from the IR model's own name. `wireInterfaceName`
79
+ * consults this set to decide whether to fire the "single-form wire" case:
80
+ * that case is *only* meant for structurally-renamed models, where the
81
+ * baseline owns a `*Response` interface representing the wire shape with no
82
+ * separate `*Wire` companion. Without this signal, a freshly-emitted model
83
+ * whose IR name already ends in `Response` (e.g. `CreateDataKeyResponse`)
84
+ * would land in the same case as soon as a prior buggy regen wrote the
85
+ * baseline — producing two `export interface CreateDataKeyResponse { ... }`
86
+ * declarations in the same file.
87
+ */
88
+ let structurallyRenamedDomainNames: Set<string> = new Set();
89
+ export function setStructurallyRenamedDomainNames(names: Set<string>): void {
90
+ structurallyRenamedDomainNames = names;
91
+ }
92
+
76
93
  /**
77
94
  * Wire/response interface name.
78
95
  *
@@ -97,7 +114,14 @@ export function wireInterfaceName(domainName: string): string {
97
114
  if (domainName.endsWith('Response')) {
98
115
  const wireForm = `${domainName}Wire`;
99
116
  if (baselineInterfaceNames.has(wireForm)) return wireForm;
100
- if (baselineInterfaceNames.has(domainName)) return domainName;
117
+ // Single-form case (#3 in the docstring): only fire when the resolver
118
+ // structurally renamed an IR model to this baseline name. Otherwise a
119
+ // fresh `CreateDataKeyResponse`-style IR model would collapse onto its
120
+ // own name as soon as one buggy regen wrote `CreateDataKeyResponse` to
121
+ // the baseline, perpetuating the duplicate-interface emission.
122
+ if (structurallyRenamedDomainNames.has(domainName) && baselineInterfaceNames.has(domainName)) {
123
+ return domainName;
124
+ }
101
125
  return wireForm;
102
126
  }
103
127
  return `${domainName}Response`;
@@ -698,7 +698,15 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
698
698
  ? `./interfaces/${fileName(name)}.interface`
699
699
  : `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
700
700
  if (usedWireTypes.has(resolved)) {
701
- lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
701
+ const wireName = wireInterfaceName(resolved);
702
+ // When the wire name collapsed onto the domain name (single-form
703
+ // structural-rename emission), import once — otherwise we ship
704
+ // `import type { ReadObjectResponse, ReadObjectResponse }`.
705
+ if (wireName === resolved) {
706
+ lines.push(`import type { ${resolved} } from '${relPath}';`);
707
+ } else {
708
+ lines.push(`import type { ${resolved}, ${wireName} } from '${relPath}';`);
709
+ }
702
710
  } else {
703
711
  lines.push(`import type { ${resolved} } from '${relPath}';`);
704
712
  }
package/src/node/tests.ts CHANGED
@@ -682,7 +682,14 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
682
682
  const fieldAccessor = isDateTime ? `${accessor}.${domainField}.toISOString()` : `${accessor}.${domainField}`;
683
683
  // When a field has an example value, use it as the expected assertion value
684
684
  if (field.example !== undefined) {
685
- if (typeof field.example === 'object' && field.example !== null) {
685
+ // A null example on a nullable field must assert `toBeNull()` on the
686
+ // raw value — calling `.toISOString()` on null throws at runtime, and
687
+ // `.toBe(null)` against any non-null value never matches.
688
+ if (field.example === null) {
689
+ assertions.push(`expect(${accessor}.${domainField}).toBeNull();`);
690
+ continue;
691
+ }
692
+ if (typeof field.example === 'object') {
686
693
  // Objects and arrays need toEqual with JSON serialization
687
694
  assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
688
695
  } else {
package/src/node/utils.ts CHANGED
@@ -281,6 +281,7 @@ export {
281
281
  isListMetadataModel,
282
282
  isListWrapperModel,
283
283
  collectNonPaginatedResponseModelNames,
284
+ collectReferencedListMetadataModels,
284
285
  } from '../shared/model-utils.js';
285
286
 
286
287
  function modelFingerprint(model: Model): string {
@@ -2,6 +2,7 @@ import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
 
3
3
  import { fileName, fieldName } from './naming.js';
4
4
  import { isListMetadataModel, isListWrapperModel } from './models.js';
5
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
6
 
6
7
  /**
7
8
  * Prefix mapping for generating realistic ID fixture values.
@@ -36,9 +37,12 @@ export function generateFixtures(spec: {
36
37
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
37
38
  const files: { path: string; content: string }[] = [];
38
39
 
40
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
41
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
42
+
39
43
  for (const model of spec.models) {
40
- if (isListMetadataModel(model)) continue;
41
- if (isListWrapperModel(model)) continue;
44
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
45
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
42
46
  // Skip models with no fields — these are typically discriminated unions
43
47
  // with hand-maintained @oagen-ignore overrides; generated empty fixtures
44
48
  // would not match the override's required fields.
@@ -119,6 +123,22 @@ export function generateModelFixture(
119
123
  }
120
124
  }
121
125
 
126
+ if (model.discriminator) {
127
+ const [firstValue, variantName] = Object.entries(model.discriminator.mapping)[0];
128
+ fixture[model.discriminator.property] = firstValue;
129
+ const variantModel = modelMap.get(variantName);
130
+ if (variantModel) {
131
+ for (const field of variantModel.fields) {
132
+ if (!(field.name in fixture)) {
133
+ fixture[field.name] =
134
+ field.example !== undefined
135
+ ? field.example
136
+ : generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
122
142
  return fixture;
123
143
  }
124
144
 
@@ -46,12 +46,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
46
46
  // emitted — the resource code references them by name and SyncPage doesn't
47
47
  // wrap them.
48
48
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
49
+ // ListMetadata-shape models referenced by a surviving non-paginated wrapper
50
+ // (e.g. vault's `VersionListResponse`) must still emit a dataclass —
51
+ // otherwise the wrapper's module imports a class that was never written.
52
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
49
53
 
50
54
  for (const model of models) {
51
55
  // Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
52
56
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
53
57
  // Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
54
- if (isListMetadataModel(model)) continue;
58
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
55
59
 
56
60
  const service = modelToService.get(model.name);
57
61
  const dirName = resolveDir(service);
@@ -875,5 +879,6 @@ import {
875
879
  isListMetadataModel,
876
880
  isListWrapperModel,
877
881
  collectNonPaginatedResponseModelNames,
882
+ collectReferencedListMetadataModels,
878
883
  } from '../shared/model-utils.js';
879
884
  export { isListMetadataModel, isListWrapperModel };
@@ -22,6 +22,7 @@ import { resolveResourceClassName, bodyParamName } from './resources.js';
22
22
  import { buildServiceAccessPaths } from './client.js';
23
23
  import { generateFixtures, generateModelFixture } from './fixtures.js';
24
24
  import { isListWrapperModel, isListMetadataModel } from './models.js';
25
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
25
26
  import {
26
27
  groupByMount,
27
28
  buildResolvedLookup,
@@ -1396,8 +1397,13 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1396
1397
  // A model is request-only if it's used as a request body but never as a response
1397
1398
  for (const name of responseModelNames) requestOnlyModelNames.delete(name);
1398
1399
 
1400
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
1401
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
1399
1402
  const models = spec.models.filter(
1400
- (m) => !isListWrapperModel(m) && !isListMetadataModel(m) && !requestOnlyModelNames.has(m.name),
1403
+ (m) =>
1404
+ !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1405
+ !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1406
+ !requestOnlyModelNames.has(m.name),
1401
1407
  );
1402
1408
  if (models.length === 0) return null;
1403
1409
 
@@ -29,8 +29,10 @@ function mapSorbetType(ref: TypeRef): string {
29
29
  return `WorkOS::${className(ref.name)}`;
30
30
  case 'enum':
31
31
  return 'String';
32
- case 'nullable':
33
- return `T.nilable(${mapSorbetType(ref.inner)})`;
32
+ case 'nullable': {
33
+ const inner = mapSorbetType(ref.inner);
34
+ return inner === 'T.untyped' ? inner : `T.nilable(${inner})`;
35
+ }
34
36
  case 'literal':
35
37
  if (typeof ref.value === 'string') return 'String';
36
38
  if (ref.value === null) return 'NilClass';
package/src/ruby/rbi.ts CHANGED
@@ -316,6 +316,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
316
316
 
317
317
  /** Unwrap T.nilable(...) if already wrapped, to avoid double-wrapping. */
318
318
  function unwrapNilable(type: string): string {
319
+ if (type === 'T.untyped') return type;
319
320
  const match = type.match(/^T\.nilable\((.+)\)$/);
320
321
  return match ? match[1] : type;
321
322
  }
@@ -49,6 +49,22 @@ export function generateModelFixture(
49
49
  fromExample !== undefined ? fromExample : exampleFor(field.type, modelMap, enumMap, visiting, field.name);
50
50
  }
51
51
 
52
+ if (model.discriminator) {
53
+ const [firstValue, variantName] = Object.entries(model.discriminator.mapping)[0];
54
+ result[model.discriminator.property] = firstValue;
55
+ const variantModel = modelMap.get(variantName);
56
+ if (variantModel) {
57
+ for (const field of variantModel.fields) {
58
+ if (!(field.name in result)) {
59
+ if (!field.required) continue;
60
+ const fromExample = exampleFromSpec(field.example, field.type, enumMap);
61
+ result[field.name] =
62
+ fromExample !== undefined ? fromExample : exampleFor(field.type, modelMap, enumMap, visiting, field.name);
63
+ }
64
+ }
65
+ }
66
+ }
67
+
52
68
  visiting.delete(model.name);
53
69
  return result;
54
70
  }