@workos/oagen-emitters 0.14.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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BxVeu2v9.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DRGwxN88.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.0",
3
+ "version": "0.14.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -20,7 +20,11 @@ import {
20
20
  } from './naming.js';
21
21
 
22
22
  // Import and re-export shared model detection utilities
23
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
23
+ import {
24
+ isListWrapperModel,
25
+ isListMetadataModel,
26
+ collectNonPaginatedResponseModelNames,
27
+ } from '../shared/model-utils.js';
24
28
  export { isListWrapperModel, isListMetadataModel };
25
29
 
26
30
  /**
@@ -74,6 +78,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
74
78
  // (see workos-dotnet#248 with `CreateUserApiKey` /
75
79
  // `UserManagementCreateApiKeyOptions`). Skip emission for those.
76
80
  const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
81
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
82
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
83
+ // code references them by name and the pagination iterator doesn't unwrap them.
84
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
85
+ const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
77
86
 
78
87
  // Build a lookup of base model field C# names → C# types for inheritance.
79
88
  // Variant models skip inherited fields and use `new` for type-divergent ones.
@@ -81,9 +90,12 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
81
90
  if (discCtx) {
82
91
  for (const model of models) {
83
92
  if (discCtx.discriminatorBases.has(model.name)) {
93
+ const baseClassName = modelClassName(model.name);
84
94
  const fieldMap = new Map<string, string>();
85
95
  for (const field of model.fields) {
86
- fieldMap.set(fieldName(field.name), mapTypeRef(field.type));
96
+ let csName = fieldName(field.name);
97
+ if (csName === baseClassName) csName = `${csName}Value`;
98
+ fieldMap.set(csName, mapTypeRef(field.type));
87
99
  }
88
100
  baseFieldLookup.set(model.name, fieldMap);
89
101
  }
@@ -91,7 +103,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
91
103
  }
92
104
 
93
105
  for (const model of models) {
94
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
106
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
95
107
  if (requestBodyOnlyNames.has(model.name)) continue;
96
108
 
97
109
  const csClassName = modelClassName(model.name);
@@ -104,7 +116,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
104
116
  const fieldTypes = model.fields.map((f) => mapTypeRef(f.type));
105
117
  const needsCollections = fieldTypes.some((t) => t.startsWith('List<') || t.startsWith('Dictionary<'));
106
118
  const needsSystem = fieldTypes.some((t) => t.includes('DateTimeOffset'));
107
- const needsJsonAttrs = model.fields.some((f) => f.required && isEnumRef(f.type));
119
+ // Required enums need JsonProperty / STJS; a field whose PascalCase name
120
+ // collides with the enclosing class needs the same imports for the wire-
121
+ // name override emitted below.
122
+ const hasClassNameCollision = model.fields.some((f) => fieldName(f.name) === csClassName);
123
+ const needsJsonAttrs = hasClassNameCollision || model.fields.some((f) => f.required && isEnumRef(f.type));
108
124
 
109
125
  lines.push(`namespace ${ctx.namespacePascal}`);
110
126
  lines.push('{');
@@ -154,7 +170,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
154
170
  // Deduplicate fields by C# property name
155
171
  const seenFieldNames = new Set<string>();
156
172
  for (const field of model.fields) {
157
- const csFieldName = fieldName(field.name);
173
+ // CS0542: a property can't share its enclosing class's name. Spec schemas
174
+ // like `Error.error` PascalCase into `Error.Error`, so suffix with `Value`
175
+ // when that happens. Track the rename so we emit an explicit
176
+ // `[JsonProperty]` attribute below — the SnakeCaseLower naming policy
177
+ // would otherwise serialize `ErrorValue` as `error_value`, not `error`.
178
+ let csFieldName = fieldName(field.name);
179
+ const collidesWithClassName = csFieldName === csClassName;
180
+ if (collidesWithClassName) csFieldName = `${csFieldName}Value`;
158
181
  if (seenFieldNames.has(csFieldName)) continue;
159
182
  seenFieldNames.add(csFieldName);
160
183
 
@@ -234,7 +257,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
234
257
  }
235
258
 
236
259
  const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
237
- lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
260
+ lines.push(
261
+ ...emitJsonPropertyAttributes(field.name, { isRequiredEnum, explicitWireName: collidesWithClassName }),
262
+ );
238
263
  // Discriminated-union-typed field: attach the variant-dispatching converter
239
264
  // so Newtonsoft picks the right subtype on deserialization. The converter
240
265
  // name is keyed off the first IR variant model name (matches how
@@ -156,7 +156,24 @@ export function isEnumRef(ref: TypeRef): boolean {
156
156
  * omission so the API returns a clear `missing required field` error instead
157
157
  * of a confusing 422.
158
158
  */
159
- export function emitJsonPropertyAttributes(_wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
159
+ export function emitJsonPropertyAttributes(
160
+ wireName: string,
161
+ options: { isRequiredEnum?: boolean; explicitWireName?: boolean } = {},
162
+ ): string[] {
163
+ // When the C# property name doesn't naturally map back to the wire name via
164
+ // the SnakeCaseLower naming policy (e.g. we suffixed the property to avoid a
165
+ // CS0542 class/member collision like `Error.Error` → `Error.ErrorValue`), the
166
+ // caller pins the wire name explicitly so JSON round-trip still works.
167
+ if (options.explicitWireName) {
168
+ if (options.isRequiredEnum) {
169
+ return [
170
+ ` [JsonProperty("${wireName}", DefaultValueHandling = DefaultValueHandling.Ignore)]`,
171
+ ` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
172
+ ` [STJS.JsonPropertyName("${wireName}")]`,
173
+ ];
174
+ }
175
+ return [` [JsonProperty("${wireName}")]`, ` [STJS.JsonPropertyName("${wireName}")]`];
176
+ }
160
177
  if (options.isRequiredEnum) {
161
178
  return [
162
179
  ` [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`,
package/src/go/models.ts CHANGED
@@ -5,7 +5,11 @@ import { className, fieldName } from './naming.js';
5
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
6
6
 
7
7
  // Import and re-export shared model detection utilities
8
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
8
+ import {
9
+ isListWrapperModel,
10
+ isListMetadataModel,
11
+ collectNonPaginatedResponseModelNames,
12
+ } from '../shared/model-utils.js';
9
13
  export { isListWrapperModel, isListMetadataModel };
10
14
 
11
15
  /**
@@ -83,12 +87,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
83
87
  lines.push('');
84
88
 
85
89
  const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
90
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
91
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
92
+ // code references them by name and the pagination iterator doesn't unwrap them.
93
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
94
+ const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
86
95
 
87
96
  // Build structural hash for deduplication
88
97
  const modelHashMap = new Map<string, string>();
89
98
  const hashGroups = new Map<string, string[]>();
90
99
  for (const model of models) {
91
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
100
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
92
101
  if (requestBodyOnly.has(model.name)) continue;
93
102
  const hash = structuralHash(model);
94
103
  modelHashMap.set(model.name, hash);
@@ -112,7 +121,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
112
121
 
113
122
  const batchedAliases = new Set<string>();
114
123
  for (const model of models) {
115
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
124
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
116
125
  if (requestBodyOnly.has(model.name)) continue;
117
126
 
118
127
  const structName = className(model.name);
@@ -2,7 +2,11 @@ import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@work
2
2
  import { mapTypeRef, discriminatedUnions } from './type-map.js';
3
3
  import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
4
4
  import { enumCanonicalMap } from './enums.js';
5
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
5
+ import {
6
+ isListWrapperModel,
7
+ isListMetadataModel,
8
+ collectNonPaginatedResponseModelNames,
9
+ } from '../shared/model-utils.js';
6
10
 
7
11
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
8
12
  const MODELS_PACKAGE = 'com.workos.models';
@@ -47,7 +51,7 @@ function promoteFieldType(f: Field): Field {
47
51
  * List wrappers (`{ data, list_metadata }`) and the shared `ListMetadata`
48
52
  * model are skipped — the hand-maintained runtime provides [Page]/[ListMetadata].
49
53
  */
50
- export function generateModels(models: Model[], _ctx: EmitterContext): GeneratedFile[] {
54
+ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
51
55
  if (models.length === 0) return [];
52
56
 
53
57
  // First pass: call mapTypeRef on every model field so discriminator info is
@@ -58,12 +62,18 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
58
62
 
59
63
  const files: GeneratedFile[] = [];
60
64
 
65
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
66
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
67
+ // code references them by name and pagination iterators don't unwrap them.
68
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
69
+ const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
70
+
61
71
  // Deduplication: identical structures become typealiases.
62
72
  // Pass 1: hash without nested-alias resolution.
63
73
  modelAliasMap = null;
64
74
  const hashGroupsPass1 = new Map<string, string[]>();
65
75
  for (const model of models) {
66
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
76
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
67
77
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
68
78
  const hash = structuralHash(model);
69
79
  if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
@@ -86,7 +96,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
86
96
  modelAliasMap = aliasOf;
87
97
  const hashGroupsPass2 = new Map<string, string[]>();
88
98
  for (const model of models) {
89
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
99
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
90
100
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
91
101
  if (aliasOf.has(model.name)) continue; // already aliased in pass 1
92
102
  const hash = structuralHash(model);
@@ -105,7 +115,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
105
115
  }
106
116
 
107
117
  for (const model of models) {
108
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
118
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
109
119
  const typeName = className(model.name);
110
120
 
111
121
  // Parent of a discriminated union: emit a sealed class.
@@ -142,7 +152,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
142
152
  // mapping so Jackson can deserialize directly to the correct concrete type.
143
153
  const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
144
154
  for (const model of models) {
145
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
155
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
146
156
  if (aliasOf.has(model.name)) continue;
147
157
  if (!isEventEnvelopeModel(model)) continue;
148
158
  const eventField = model.fields.find((f) => f.name === 'event');
package/src/node/index.ts CHANGED
@@ -38,6 +38,24 @@ import { fileName } from './naming.js';
38
38
  */
39
39
  const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
40
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
+
41
59
  function getSurface(ctx: EmitterContext): LiveSurface {
42
60
  let surface = surfaceCache.get(ctx);
43
61
  if (surface) return surface;
@@ -115,8 +133,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
115
133
  * `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
116
134
  * with no `--target` step they would otherwise land as untracked cruft.
117
135
  *
118
- * Note: pairing this with `--no-prune` is required for stable behavior — see
119
- * `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.
120
140
  */
121
141
  /**
122
142
  * `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
@@ -355,6 +375,55 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
355
375
  }
356
376
  out.push(f);
357
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
+ }
358
427
  return out;
359
428
  }
360
429
 
@@ -440,11 +509,16 @@ export const nodeEmitter: Emitter = {
440
509
 
441
510
  // Test specs and fixtures are hand-maintained except for explicitly-owned
442
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`.
443
515
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
444
516
  const nodeCtx = withNodeOperationOverrides(ctx);
445
- if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
446
517
  const surface = getSurface(nodeCtx);
447
- 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)];
448
522
  },
449
523
 
450
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,
@@ -209,12 +210,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
209
210
  }
210
211
 
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);
212
217
  for (const originalModel of models) {
213
218
  const model = projectedByName.get(originalModel.name) ?? originalModel;
214
219
  if (!reachableModels.has(model.name)) continue;
215
220
  if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
216
221
  if (isListMetadataModel(model)) continue;
217
- if (isListWrapperModel(model)) continue;
222
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
218
223
  if (discriminatedSkip?.has(model.name)) continue;
219
224
  const service = modelToService.get(model.name);
220
225
  const isOwnedModel = isNodeOwnedService(ctx, service);
@@ -733,13 +738,14 @@ export function generateSerializers(
733
738
  }
734
739
 
735
740
  const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
741
+ const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
736
742
  const eligibleModels: Model[] = [];
737
743
  for (const originalModel of models) {
738
744
  const model = projectedByName.get(originalModel.name) ?? originalModel;
739
745
  if (!serializerReachable.has(model.name)) continue;
740
746
  if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
741
747
  if (isListMetadataModel(model)) continue;
742
- if (isListWrapperModel(model)) continue;
748
+ if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
743
749
  if (discriminatedSerializerSkip?.has(model.name)) continue;
744
750
  const service = modelToService.get(model.name);
745
751
  const isOwnedModel = isNodeOwnedService(ctx, service);
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/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
@@ -36,8 +36,8 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
36
36
  * has its original fields restored — otherwise `ConnectApplication`-style
37
37
  * bases would silently lose every variant field they had previously.
38
38
  */
39
- function enrichModelsForRuby(models: Model[]): Model[] {
40
- const enriched = enrichModelsFromSpec(models);
39
+ function enrichModelsForRuby(models: Model[], enums: Enum[]): Model[] {
40
+ const enriched = enrichModelsFromSpec(models, enums);
41
41
  const originalByName = new Map(models.map((m) => [m.name, m]));
42
42
  return enriched.map((m) => {
43
43
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
@@ -54,7 +54,7 @@ export const rubyEmitter: Emitter = {
54
54
  language: 'ruby',
55
55
 
56
56
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
57
- const modelFiles = generateModels(enrichModelsForRuby(models), ctx);
57
+ const modelFiles = generateModels(enrichModelsForRuby(models, ctx.spec.enums), ctx);
58
58
  return ensureTrailingNewlines(modelFiles);
59
59
  },
60
60
 
@@ -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);
@@ -140,7 +140,11 @@ function renderField(field: Field, rustField: string, modelName: string, registr
140
140
  function renderModelsBarrel(modules: string[]): string {
141
141
  const sorted = [...new Set(modules)].sort();
142
142
  const lines: string[] = [];
143
- for (const m of sorted) lines.push(`pub mod ${m};`);
143
+ // Declare the modules privately so `pub use crate::models::*` in lib.rs only
144
+ // re-exports the struct names, not the module names themselves. Otherwise a
145
+ // module like `models::organization_membership` collides with the same-named
146
+ // `resources::organization_membership` when both barrels are glob-re-exported.
147
+ for (const m of sorted) lines.push(`mod ${m};`);
144
148
  lines.push('');
145
149
  for (const m of sorted) lines.push(`pub use ${m}::*;`);
146
150
  return lines.join('\n') + '\n';
@@ -1317,7 +1317,10 @@ function renderResourcesBarrel(exports: { module: string; struct: string }[]): s
1317
1317
  unique.sort((a, b) => a.module.localeCompare(b.module));
1318
1318
 
1319
1319
  const lines: string[] = [];
1320
- for (const { module } of unique) lines.push(`pub mod ${module};`);
1320
+ // Declare modules privately see the matching comment in `models.ts`.
1321
+ // `pub mod resources::organization_membership` would collide with the
1322
+ // same-named module re-exported via `pub use models::*` in lib.rs.
1323
+ for (const { module } of unique) lines.push(`mod ${module};`);
1321
1324
  lines.push('');
1322
1325
  for (const { module, struct } of unique) lines.push(`pub use ${module}::${struct};`);
1323
1326
  return lines.join('\n') + '\n';
@@ -1,10 +1,31 @@
1
- import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
2
- import { toSnakeCase, toUpperSnakeCase } from '@workos/oagen';
1
+ import type { Model, Field, TypeRef, Enum, Service } from '@workos/oagen';
2
+ import { toSnakeCase, toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  // @ts-ignore -- js-yaml has no type declarations in this project
6
6
  import { load as yamlLoad } from 'js-yaml';
7
7
 
8
+ /**
9
+ * Collect model names referenced as the return type of any non-paginated
10
+ * operation. The list-wrapper skip rule below assumes a wrapper is always
11
+ * replaced by the SDK's pagination machinery — but a few endpoints
12
+ * (e.g. `GET /vault/v1/kv/{id}/versions`) have a list-envelope response
13
+ * shape with no pagination params, so the parser leaves them as a plain
14
+ * model reference. We must still emit those wrappers as regular models;
15
+ * otherwise the generated resource code references an undefined name.
16
+ */
17
+ export function collectNonPaginatedResponseModelNames(services: Service[]): Set<string> {
18
+ const names = new Set<string>();
19
+ for (const service of services) {
20
+ for (const op of service.operations) {
21
+ if (op.pagination) continue;
22
+ walkTypeRef(op.response, { model: (r) => names.add(r.name) });
23
+ for (const sr of op.successResponses ?? []) walkTypeRef(sr.type, { model: (r) => names.add(r.name) });
24
+ }
25
+ }
26
+ return names;
27
+ }
28
+
8
29
  /**
9
30
  * Detect whether a model is a list wrapper -- the standard paginated
10
31
  * list envelope with `data` (array), `list_metadata`, and optionally `object: 'list'`.
@@ -503,7 +524,7 @@ export function detectDiscriminators(models: Model[]): Model[] {
503
524
  * Returns a new array of enriched models (original models are not mutated).
504
525
  * Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
505
526
  */
506
- export function enrichModelsFromSpec(models: Model[]): Model[] {
527
+ export function enrichModelsFromSpec(models: Model[], enums: Enum[] = []): Model[] {
507
528
  const spec = loadRawSpec();
508
529
  if (!spec) {
509
530
  _lastSyntheticEnums = [];
@@ -518,6 +539,17 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
518
539
  collector.usedNames.add(m.name);
519
540
  collector.usedNames.add(toSnakeCase(m.name));
520
541
  }
542
+ // Seed existing IR enum names too. The parser already emits inline enums
543
+ // like `DataIntegrationAccessTokenResponseError` (PascalCase); without this
544
+ // seed, the synthetic path would emit a sibling enum named
545
+ // `DataIntegrationAccessTokenResponse_error`, and language emitters that
546
+ // PascalCase-normalize identifiers (Ruby, Go, PHP, Python) would collapse
547
+ // both onto the same class/file path — the second overwrites the first
548
+ // with a `X = X` self-alias.
549
+ for (const e of enums) {
550
+ collector.usedNames.add(e.name);
551
+ collector.usedNames.add(toSnakeCase(e.name));
552
+ }
521
553
 
522
554
  const enriched = models.map((model) => {
523
555
  const rawSchema = lookupRawSchema(model.name);
@@ -110,7 +110,7 @@ describe('rust/models', () => {
110
110
  const event = files.find((f) => f.path === 'src/models/event.rs')!;
111
111
  expect(event.content).toContain('pub payload: EventPayloadOneOf,');
112
112
  const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
113
- expect(barrel.content).toContain('pub mod _unions;');
113
+ expect(barrel.content).toContain('mod _unions;');
114
114
  // The _unions.rs file is rendered by generateClient (the final structural
115
115
  // pass) so resource-side body unions can join the same registry.
116
116
  const clientFiles = generateClient(emptySpec, ctx, registry);
@@ -170,8 +170,8 @@ describe('rust/models', () => {
170
170
  ];
171
171
  const files = generateModels(models, ctx, new UnionRegistry());
172
172
  const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
173
- expect(barrel.content).toContain('pub mod alpha;');
174
- expect(barrel.content).toContain('pub mod beta;');
173
+ expect(barrel.content).toContain('mod alpha;');
174
+ expect(barrel.content).toContain('mod beta;');
175
175
  expect(barrel.content).toContain('pub use alpha::*;');
176
176
  });
177
177
  });