@workos/oagen-emitters 0.13.0 → 0.14.1

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/src/node/index.ts CHANGED
@@ -16,6 +16,8 @@ import { generateEnums as generateEnumFiles } from './enums.js';
16
16
  import { generateResources, resolveResourceClassName, resolveResourceDir } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
18
  import { generateTests as generateTestFiles } from './tests.js';
19
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
20
+ import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
19
21
  import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
20
22
  import {
21
23
  setBaselineSerializedNames,
@@ -36,6 +38,24 @@ import { fileName } from './naming.js';
36
38
  */
37
39
  const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
38
40
 
41
+ /**
42
+ * Paths the node emitter has produced so far in this ctx, accumulated across
43
+ * `applyLiveSurface` calls. Drives `carryForwardManagedFiles` so files in the
44
+ * prior manifest that we did not re-emit this run still land in the new
45
+ * manifest as "still managed" — without that, the orchestrator's prune diff
46
+ * treats every untouched autogen file as stale.
47
+ */
48
+ const emittedPathsCache = new WeakMap<EmitterContext, Set<string>>();
49
+
50
+ function getEmittedPaths(ctx: EmitterContext): Set<string> {
51
+ let set = emittedPathsCache.get(ctx);
52
+ if (!set) {
53
+ set = new Set();
54
+ emittedPathsCache.set(ctx, set);
55
+ }
56
+ return set;
57
+ }
58
+
39
59
  function getSurface(ctx: EmitterContext): LiveSurface {
40
60
  let surface = surfaceCache.get(ctx);
41
61
  if (surface) return surface;
@@ -113,8 +133,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
113
133
  * `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
114
134
  * with no `--target` step they would otherwise land as untracked cruft.
115
135
  *
116
- * Note: pairing this with `--no-prune` is required for stable behavior — see
117
- * `scripts/sdk-generate.sh` in the spec repo, which enables it for `--lang node`.
136
+ * Note: the carry-forward step in `generateTests` re-declares prior-manifest
137
+ * paths we didn't touch this run, so the orchestrator's prune diff stays
138
+ * accurate without needing `--no-prune` at the call site. See
139
+ * `carryForwardManagedFiles` below.
118
140
  */
119
141
  /**
120
142
  * `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
@@ -214,7 +236,10 @@ function computeOwnedServiceDirs(ctx: EmitterContext): Set<string> {
214
236
  const mountGroups = groupByMount(ctx);
215
237
  const services =
216
238
  mountGroups.size > 0
217
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
239
+ ? [...mountGroups].map(([name, group]) => ({
240
+ name,
241
+ operations: group.operations,
242
+ }))
218
243
  : ctx.spec.services;
219
244
  const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
220
245
 
@@ -233,7 +258,10 @@ function computeAdoptedServiceDirs(ctx: EmitterContext, surface: LiveSurface): S
233
258
  const mountGroups = groupByMount(ctx);
234
259
  const services =
235
260
  mountGroups.size > 0
236
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
261
+ ? [...mountGroups].map(([name, group]) => ({
262
+ name,
263
+ operations: group.operations,
264
+ }))
237
265
  : ctx.spec.services;
238
266
  const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
239
267
 
@@ -347,22 +375,110 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
347
375
  }
348
376
  out.push(f);
349
377
  }
378
+ const emitted = getEmittedPaths(ctx);
379
+ for (const f of out) emitted.add(f.path);
380
+ return out;
381
+ }
382
+
383
+ /**
384
+ * Re-declare prior-manifest paths that we did not emit this run so manifest
385
+ * pruning can tell "intentionally removed" from "untouched but still managed."
386
+ *
387
+ * The node emitter only outputs files it actually wants to write each run —
388
+ * untouched-but-up-to-date autogen files don't come back through any
389
+ * `generateXxx` method. Without this carry-forward, the orchestrator's
390
+ * `prevManifest.files − currentEmission` diff treats every such file as stale
391
+ * and prunes the whole tree on a regen. That's why `scripts/sdk-generate.sh`
392
+ * historically paired the node emitter with `--no-prune` — at the cost of
393
+ * never pruning legitimately-removed files (e.g. an enum file orphaned by a
394
+ * `schemaNameTransform` rename like `RadarAction` → `RadarListAction`).
395
+ *
396
+ * The carry-forward entry uses `skipIfExists: true`, so writer.ts skips the
397
+ * write and only ensures the header is present (no-op for files that already
398
+ * have it). The path still lands in `outputEmittedPaths` and therefore in the
399
+ * new manifest, which restores correct prune semantics.
400
+ *
401
+ * Files dropped from the carry-forward set:
402
+ * - Not on disk anymore (file was hand-deleted — let prune confirm absence).
403
+ * - `@oagen-ignore-file` protected (user has explicitly taken ownership).
404
+ * - `.ts` files that no longer carry the auto-gen header (user has taken
405
+ * ownership in-place; the next prune cycle will clear the manifest entry).
406
+ */
407
+ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
408
+ const priorPaths = ctx.priorTargetManifestPaths;
409
+ if (!priorPaths || priorPaths.size === 0) return [];
410
+
411
+ const emitted = getEmittedPaths(ctx);
412
+ const out: GeneratedFile[] = [];
413
+ for (const relPath of priorPaths) {
414
+ if (emitted.has(relPath)) continue;
415
+ if (!surface.files.has(relPath)) continue;
416
+ if (surface.protectedFiles.has(relPath)) continue;
417
+ if (relPath.endsWith('.ts') && !surface.autogenFiles.has(relPath)) continue;
418
+
419
+ out.push({
420
+ path: relPath,
421
+ content: '',
422
+ skipIfExists: true,
423
+ headerPlacement: 'skip',
424
+ });
425
+ emitted.add(relPath);
426
+ }
350
427
  return out;
351
428
  }
352
429
 
430
+ /**
431
+ * Flatten oneOf / allOf+oneOf variant fields from the raw spec onto each
432
+ * model. `enrichModelsFromSpec` produces (a) extra optional fields on models
433
+ * whose schema is `allOf [base, oneOf [...]]`, and (b) synthetic models /
434
+ * enums for inline objects encountered inside variants (e.g. the inline
435
+ * `redirect_uris` item shape on `ConnectApplication`).
436
+ *
437
+ * Node, like Go / Kotlin / .NET, emits flat interfaces rather than a sum
438
+ * type, so on `enrichModelsFromSpec`-marked discriminated bases we restore
439
+ * the original IR fields — otherwise the base interface would be empty.
440
+ * A future change can emit a real TS discriminated union; for now the goal
441
+ * is parity with the other flat-emit languages so every variant field is
442
+ * at least reachable.
443
+ */
444
+ function enrichModelsForNode(models: Model[]): Model[] {
445
+ const enriched = enrichModelsFromSpec(models);
446
+ const originalByName = new Map(models.map((m) => [m.name, m]));
447
+ return enriched.map((m) => {
448
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
449
+ const original = originalByName.get(m.name);
450
+ if (original && original.fields.length > 0) {
451
+ return { ...m, fields: original.fields };
452
+ }
453
+ }
454
+ return m;
455
+ });
456
+ }
457
+
353
458
  export const nodeEmitter: Emitter = {
354
459
  language: 'node',
355
460
 
356
461
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
357
462
  const nodeCtx = withNodeOperationOverrides(ctx);
358
463
  const surface = getSurface(nodeCtx);
359
- return applyLiveSurface(generateModelsAndSerializers(models, nodeCtx), nodeCtx, surface);
464
+ const enriched = enrichModelsForNode(models);
465
+ // Detect `allOf [base, oneOf [variant, …]]` schemas and hand them off
466
+ // to the discriminated-models module. Leave the model in the standard
467
+ // pipeline's input so its field-type deps stay reachable, but stash the
468
+ // name set on ctx so models.ts skips emitting an interface/serializer —
469
+ // the discriminated module owns those paths instead.
470
+ const discPlans = planDiscriminatedModels(enriched, nodeCtx);
471
+ (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = new Set(discPlans.keys());
472
+ const standardFiles = generateModelsAndSerializers(enriched, nodeCtx);
473
+ const discFiles = generateDiscriminatedFiles(discPlans, nodeCtx);
474
+ return applyLiveSurface([...standardFiles, ...discFiles], nodeCtx, surface);
360
475
  },
361
476
 
362
477
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
363
478
  const nodeCtx = withNodeOperationOverrides(ctx);
364
479
  const surface = getSurface(nodeCtx);
365
- return applyLiveSurface(generateEnumFiles(enums, nodeCtx), nodeCtx, surface);
480
+ const syntheticEnums = getSyntheticEnums();
481
+ return applyLiveSurface(generateEnumFiles([...enums, ...syntheticEnums], nodeCtx), nodeCtx, surface);
366
482
  },
367
483
 
368
484
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
@@ -374,7 +490,11 @@ export const nodeEmitter: Emitter = {
374
490
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
375
491
  const nodeCtx = withNodeOperationOverrides(ctx);
376
492
  const surface = getSurface(nodeCtx);
377
- return applyLiveSurface(generateClient(spec, nodeCtx), nodeCtx, surface);
493
+ // `nodeCtx.spec` has the synthetic models that `enrichModelsFromSpec`
494
+ // produced (e.g. inline-object item types like `ConnectApplicationRedirectUri`).
495
+ // The `spec` param is the engine's pre-enrichment spec, so the barrel
496
+ // generator would miss those synthetic interfaces. Use the enriched one.
497
+ return applyLiveSurface(generateClient(nodeCtx.spec, nodeCtx), nodeCtx, surface);
378
498
  },
379
499
 
380
500
  // workos-node ships its own exception hierarchy under src/common/exceptions/.
@@ -389,11 +509,16 @@ export const nodeEmitter: Emitter = {
389
509
 
390
510
  // Test specs and fixtures are hand-maintained except for explicitly-owned
391
511
  // service directories.
512
+ //
513
+ // This is also the last `generateXxx` hook in `generateAllFiles`, so it's
514
+ // where we tack on the carry-forward set — see `carryForwardManagedFiles`.
392
515
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
393
516
  const nodeCtx = withNodeOperationOverrides(ctx);
394
- if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
395
517
  const surface = getSurface(nodeCtx);
396
- return applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface);
518
+ const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
519
+ ? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
520
+ : [];
521
+ return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
397
522
  },
398
523
 
399
524
  // No operations map needed — the manifest belongs to the staging+target flow,
@@ -23,6 +23,7 @@ import {
23
23
  createServiceDirResolver,
24
24
  isListMetadataModel,
25
25
  isListWrapperModel,
26
+ collectNonPaginatedResponseModelNames,
26
27
  buildDeduplicationMap,
27
28
  relativeImport,
28
29
  modelHasNewFields,
@@ -130,6 +131,13 @@ function isSupportedFieldType(
130
131
  // silently dropped on first emission because the target interface
131
132
  // (`UserObject` under the adopted `connect/` dir) hasn't landed yet.
132
133
  if (isAdoptedModelName(ref.name)) return true;
134
+ // Synthetic models produced by `enrichModelsFromSpec` (e.g. the
135
+ // inline-object item type for `ConnectApplication.redirect_uris`)
136
+ // are added to the models list passed into this generation pass —
137
+ // and hence into `shared.modelToService` — but won't yet exist on
138
+ // disk or in `apiSurface`. Accept them so their parent field
139
+ // survives field-projection.
140
+ if (shared.modelToService.has(ref.name)) return true;
133
141
  const relPath = `src/${shared.resolveDir(shared.modelToService.get(ref.name))}/interfaces/${fileName(ref.name)}.interface.ts`;
134
142
  return liveSurfaceHasManagedFile(relPath);
135
143
  }
@@ -201,12 +209,18 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
201
209
  }
202
210
  }
203
211
 
212
+ const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
213
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
214
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
215
+ // code references them by name and pagination iterators don't unwrap them.
216
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
204
217
  for (const originalModel of models) {
205
218
  const model = projectedByName.get(originalModel.name) ?? originalModel;
206
219
  if (!reachableModels.has(model.name)) continue;
207
220
  if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
208
221
  if (isListMetadataModel(model)) continue;
209
- if (isListWrapperModel(model)) continue;
222
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
223
+ if (discriminatedSkip?.has(model.name)) continue;
210
224
  const service = modelToService.get(model.name);
211
225
  const isOwnedModel = isNodeOwnedService(ctx, service);
212
226
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
@@ -723,13 +737,16 @@ export function generateSerializers(
723
737
  }
724
738
  }
725
739
 
740
+ const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
741
+ const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
726
742
  const eligibleModels: Model[] = [];
727
743
  for (const originalModel of models) {
728
744
  const model = projectedByName.get(originalModel.name) ?? originalModel;
729
745
  if (!serializerReachable.has(model.name)) continue;
730
746
  if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
731
747
  if (isListMetadataModel(model)) continue;
732
- if (isListWrapperModel(model)) continue;
748
+ if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
749
+ if (discriminatedSerializerSkip?.has(model.name)) continue;
733
750
  const service = modelToService.get(model.name);
734
751
  const isOwnedModel = isNodeOwnedService(ctx, service);
735
752
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
@@ -928,7 +945,10 @@ function buildGeneratedResourceModelUsage(
928
945
  const mountGroups = groupByMount(ctx);
929
946
  const services: Service[] =
930
947
  mountGroups.size > 0
931
- ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
948
+ ? [...mountGroups].map(([name, group]) => ({
949
+ name,
950
+ operations: group.operations,
951
+ }))
932
952
  : ctx.spec.services;
933
953
 
934
954
  for (const service of services) {
@@ -1,4 +1,5 @@
1
- import type { EmitterContext, ResolvedOperation } from '@workos/oagen';
1
+ import type { EmitterContext, Model, ResolvedOperation } from '@workos/oagen';
2
+ import { enrichModelsFromSpec } from '../shared/model-utils.js';
2
3
 
3
4
  type OperationOverride = {
4
5
  methodName?: string;
@@ -42,17 +43,52 @@ function operationKey(resolved: ResolvedOperation): string {
42
43
  return `${resolved.operation.httpMethod.toUpperCase()} ${resolved.operation.path}`;
43
44
  }
44
45
 
46
+ /**
47
+ * Apply oneOf / allOf+oneOf enrichment (flattening variant fields onto the
48
+ * parent model, plus synthetic models/enums for inline shapes) so the rest
49
+ * of the Node emitter sees a richer `spec.models`.
50
+ *
51
+ * Without this, `ConnectApplication` (and any other `allOf [base, oneOf [...]]`
52
+ * schema whose first variant is itself wrapped in `allOf`) loses every
53
+ * non-M2M field — the IR parser's discriminator detection silently skips
54
+ * variants whose properties live behind another `allOf`. Mirrors what the
55
+ * Go / Kotlin / .NET emitters already do.
56
+ *
57
+ * Discriminated bases produced by `enrichModelsFromSpec` get their original
58
+ * fields restored — Node emits flat interfaces today, not TS sum types, so
59
+ * an empty base would otherwise drop the common fields.
60
+ */
61
+ function enrichSpecModels(models: readonly Model[]): Model[] {
62
+ const enriched = enrichModelsFromSpec(models as Model[]);
63
+ const originalByName = new Map(models.map((m) => [m.name, m]));
64
+ return enriched.map((m) => {
65
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
66
+ const original = originalByName.get(m.name);
67
+ if (original && original.fields.length > 0) {
68
+ return { ...m, fields: original.fields };
69
+ }
70
+ }
71
+ return m;
72
+ });
73
+ }
74
+
45
75
  export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext {
46
76
  const cached = contextCache.get(ctx);
47
77
  if (cached) return cached;
48
78
 
79
+ const enrichedModels = enrichSpecModels(ctx.spec.models);
80
+ const specChanged =
81
+ enrichedModels.length !== ctx.spec.models.length || enrichedModels.some((m, i) => m !== ctx.spec.models[i]);
82
+ const enrichedSpec = specChanged ? { ...ctx.spec, models: enrichedModels } : ctx.spec;
83
+
49
84
  const resolvedOperations = ctx.resolvedOperations;
50
85
  if (!resolvedOperations?.length) {
51
- contextCache.set(ctx, ctx);
52
- return ctx;
86
+ const next = specChanged ? { ...ctx, spec: enrichedSpec } : ctx;
87
+ contextCache.set(ctx, next);
88
+ return next;
53
89
  }
54
90
 
55
- let changed = false;
91
+ let opsChanged = false;
56
92
  const nextResolved = resolvedOperations.map((resolved) => {
57
93
  const override = OPERATION_OVERRIDES[operationKey(resolved)];
58
94
  if (!override) return resolved;
@@ -63,7 +99,7 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
63
99
  return resolved;
64
100
  }
65
101
 
66
- changed = true;
102
+ opsChanged = true;
67
103
  return {
68
104
  ...resolved,
69
105
  methodName,
@@ -71,7 +107,14 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
71
107
  };
72
108
  });
73
109
 
74
- const next = changed ? { ...ctx, resolvedOperations: nextResolved } : ctx;
110
+ const next =
111
+ opsChanged || specChanged
112
+ ? {
113
+ ...ctx,
114
+ ...(opsChanged ? { resolvedOperations: nextResolved } : {}),
115
+ ...(specChanged ? { spec: enrichedSpec } : {}),
116
+ }
117
+ : ctx;
75
118
  contextCache.set(ctx, next);
76
119
  return next;
77
120
  }
package/src/node/utils.ts CHANGED
@@ -277,7 +277,11 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
277
277
  return false;
278
278
  }
279
279
 
280
- export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
280
+ export {
281
+ isListMetadataModel,
282
+ isListWrapperModel,
283
+ collectNonPaginatedResponseModelNames,
284
+ } from '../shared/model-utils.js';
281
285
 
282
286
  function modelFingerprint(model: Model): string {
283
287
  const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
package/src/php/index.ts CHANGED
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { initializeEnumDedup } from './naming.js';
21
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
22
 
22
23
  /** Initialize enum deduplication from spec data. */
23
24
  function ensureNamingInitialized(ctx: EmitterContext): void {
@@ -34,17 +35,39 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
34
35
  return files;
35
36
  }
36
37
 
38
+ /**
39
+ * Flatten oneOf / allOf+oneOf variant fields onto each base model and pull
40
+ * in synthetic models / enums for inline variant shapes. PHP emits flat
41
+ * classes (no sum types), so a discriminated base whose IR fields the
42
+ * parser stripped (post-allOf-aware detection) gets its original fields
43
+ * restored to avoid silently dropping variant data.
44
+ */
45
+ function enrichModelsForPhp(models: Model[]): Model[] {
46
+ const enriched = enrichModelsFromSpec(models);
47
+ const originalByName = new Map(models.map((m) => [m.name, m]));
48
+ return enriched.map((m) => {
49
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
50
+ const original = originalByName.get(m.name);
51
+ if (original && original.fields.length > 0) {
52
+ return { ...m, fields: original.fields };
53
+ }
54
+ }
55
+ return m;
56
+ });
57
+ }
58
+
37
59
  export const phpEmitter: Emitter = {
38
60
  language: 'php',
39
61
 
40
62
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
41
63
  ensureNamingInitialized(ctx);
42
- return ensureTrailingNewlines(generateModels(models, ctx));
64
+ return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
43
65
  },
44
66
 
45
67
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
46
68
  ensureNamingInitialized(ctx);
47
- return ensureTrailingNewlines(generateEnums(enums, ctx));
69
+ const syntheticEnums = getSyntheticEnums();
70
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
48
71
  },
49
72
 
50
73
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
package/src/php/models.ts CHANGED
@@ -4,7 +4,11 @@ import { className, enumClassName, fieldName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
5
 
6
6
  // Import and re-export shared model detection utilities
7
- import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
7
+ import {
8
+ isListMetadataModel,
9
+ isListWrapperModel,
10
+ collectNonPaginatedResponseModelNames,
11
+ } from '../shared/model-utils.js';
8
12
  export { isListMetadataModel, isListWrapperModel };
9
13
 
10
14
  /**
@@ -32,9 +36,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
32
36
  overwriteExisting: true,
33
37
  });
34
38
 
39
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
40
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
41
+ // code references them by name and the pagination iterator doesn't unwrap them.
42
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
43
+
35
44
  for (const model of models) {
36
45
  if (isListMetadataModel(model)) continue;
37
- if (isListWrapperModel(model)) continue;
46
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
38
47
  const name = className(model.name);
39
48
  const lines: string[] = [];
40
49
 
@@ -41,9 +41,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
41
41
  // oneOf enrichment collide with existing IR models in snake_case.
42
42
  const emittedFilePaths = new Set<string>();
43
43
 
44
+ // Wrappers referenced as a non-paginated operation response (e.g.
45
+ // `VersionListResponse` for `GET /vault/v1/kv/{id}/versions`) must still be
46
+ // emitted — the resource code references them by name and SyncPage doesn't
47
+ // wrap them.
48
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
49
+
44
50
  for (const model of models) {
45
51
  // Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
46
- if (isListWrapperModel(model)) continue;
52
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
47
53
  // Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
48
54
  if (isListMetadataModel(model)) continue;
49
55
 
@@ -865,5 +871,9 @@ function serializeField(ref: any, accessor: string): string {
865
871
  }
866
872
 
867
873
  // Import and re-export shared model detection utilities
868
- import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
874
+ import {
875
+ isListMetadataModel,
876
+ isListWrapperModel,
877
+ collectNonPaginatedResponseModelNames,
878
+ } from '../shared/model-utils.js';
869
879
  export { isListMetadataModel, isListWrapperModel };
@@ -1053,8 +1053,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1053
1053
 
1054
1054
  for (const op of allOperations) {
1055
1055
  const plan = planOperation(op);
1056
- if (plan.responseModelName && !listWrapperNames.has(plan.responseModelName)) {
1057
- modelImports.add(plan.responseModelName);
1056
+ if (plan.responseModelName) {
1057
+ // List-wrapper responses are normally replaced by SyncPage on paginated
1058
+ // ops, so the wrapper itself is never referenced. On non-paginated ops
1059
+ // (e.g. `GET /vault/v1/kv/{id}/versions` → `VersionListResponse`) the
1060
+ // resource method still returns the wrapper by name and must import it.
1061
+ if (!listWrapperNames.has(plan.responseModelName) || !plan.isPaginated) {
1062
+ modelImports.add(plan.responseModelName);
1063
+ }
1058
1064
  }
1059
1065
  if (op.requestBody?.kind === 'model') {
1060
1066
  const requestBodyRef = op.requestBody;
package/src/ruby/index.ts CHANGED
@@ -15,6 +15,7 @@ import { generateClient } from './client.js';
15
15
  import { generateTests } from './tests.js';
16
16
  import { buildOperationsMap } from './manifest.js';
17
17
  import { generateRbiFiles } from './rbi.js';
18
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
18
19
 
19
20
  /** Ensure every generated file's content ends with a trailing newline. */
20
21
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -26,16 +27,40 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
26
27
  return files;
27
28
  }
28
29
 
30
+ /**
31
+ * Flatten oneOf / allOf+oneOf variant fields onto each base model and pick
32
+ * up the synthetic models / enums `enrichModelsFromSpec` produces for inline
33
+ * variant shapes. Ruby emits flat hash-backed models, not sum types, so a
34
+ * discriminated base whose IR fields were stripped (the new EventSchema-
35
+ * style behaviour after the parser learned to walk allOf-wrapped variants)
36
+ * has its original fields restored — otherwise `ConnectApplication`-style
37
+ * bases would silently lose every variant field they had previously.
38
+ */
39
+ function enrichModelsForRuby(models: Model[], enums: Enum[]): Model[] {
40
+ const enriched = enrichModelsFromSpec(models, enums);
41
+ const originalByName = new Map(models.map((m) => [m.name, m]));
42
+ return enriched.map((m) => {
43
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
44
+ const original = originalByName.get(m.name);
45
+ if (original && original.fields.length > 0) {
46
+ return { ...m, fields: original.fields };
47
+ }
48
+ }
49
+ return m;
50
+ });
51
+ }
52
+
29
53
  export const rubyEmitter: Emitter = {
30
54
  language: 'ruby',
31
55
 
32
56
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
33
- const modelFiles = generateModels(models, ctx);
57
+ const modelFiles = generateModels(enrichModelsForRuby(models, ctx.spec.enums), ctx);
34
58
  return ensureTrailingNewlines(modelFiles);
35
59
  },
36
60
 
37
61
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
38
- return ensureTrailingNewlines(generateEnums(enums, ctx));
62
+ const syntheticEnums = getSyntheticEnums();
63
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
39
64
  },
40
65
 
41
66
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
@@ -1,7 +1,11 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
3
3
  import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
4
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
4
+ import {
5
+ isListWrapperModel,
6
+ isListMetadataModel,
7
+ collectNonPaginatedResponseModelNames,
8
+ } from '../shared/model-utils.js';
5
9
 
6
10
  /** Folder under lib/workos/ for models not owned by any service. */
7
11
  export const SHARED_MODEL_DIR = 'shared';
@@ -77,11 +81,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
77
81
 
78
82
  const files: GeneratedFile[] = [];
79
83
 
84
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
85
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
86
+ // code references them by name and the pagination iterator doesn't unwrap them.
87
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
88
+ const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
89
+
80
90
  // Dedup identical models (by recursive structural hash).
81
91
  const recursiveHashes = buildRecursiveHashMap(models, enumNames);
82
92
  const hashGroups = new Map<string, string[]>();
83
93
  for (const m of models) {
84
- if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
94
+ if (skipAsListWrapper(m) || isListMetadataModel(m)) continue;
85
95
  const h = recursiveHashes.get(m.name) ?? '';
86
96
  if (!hashGroups.has(h)) hashGroups.set(h, []);
87
97
  hashGroups.get(h)!.push(m.name);
@@ -95,7 +105,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
95
105
  }
96
106
 
97
107
  for (const model of models) {
98
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
108
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
99
109
 
100
110
  const cls = className(model.name);
101
111
  const file = fileName(model.name);
package/src/rust/index.ts CHANGED
@@ -16,6 +16,7 @@ import { generateClient } from './client.js';
16
16
  import { generateTests } from './tests.js';
17
17
  import { buildOperationsMap } from './manifest.js';
18
18
  import { UnionRegistry } from './type-map.js';
19
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
19
20
 
20
21
  /**
21
22
  * Shared per-emit registry that collects synthesised oneOf-style unions
@@ -34,16 +35,39 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
34
35
  return files;
35
36
  }
36
37
 
38
+ /**
39
+ * Flatten oneOf / allOf+oneOf variant fields onto each base model and pull
40
+ * in synthetic models / enums for inline variant shapes. Rust emits flat
41
+ * structs (a synthesised enum-union from `UnionRegistry` exists, but the
42
+ * field-on-base pattern is what matches `ConnectApplication` today). A
43
+ * discriminated base whose IR fields the parser stripped gets its original
44
+ * fields restored to avoid losing variant data.
45
+ */
46
+ function enrichModelsForRust(models: Model[]): Model[] {
47
+ const enriched = enrichModelsFromSpec(models);
48
+ const originalByName = new Map(models.map((m) => [m.name, m]));
49
+ return enriched.map((m) => {
50
+ if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
51
+ const original = originalByName.get(m.name);
52
+ if (original && original.fields.length > 0) {
53
+ return { ...m, fields: original.fields };
54
+ }
55
+ }
56
+ return m;
57
+ });
58
+ }
59
+
37
60
  export const rustEmitter: Emitter = {
38
61
  language: 'rust',
39
62
 
40
63
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
41
64
  unionRegistry.reset();
42
- return ensureTrailingNewlines(generateModels(models, ctx, unionRegistry));
65
+ return ensureTrailingNewlines(generateModels(enrichModelsForRust(models), ctx, unionRegistry));
43
66
  },
44
67
 
45
68
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
46
- return ensureTrailingNewlines(generateEnums(enums, ctx));
69
+ const syntheticEnums = getSyntheticEnums();
70
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
47
71
  },
48
72
 
49
73
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {