@workos/oagen-emitters 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-BbSmT2kj.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.2",
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)]`,
@@ -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
 
package/src/go/models.ts CHANGED
@@ -5,7 +5,12 @@ 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
+ collectReferencedListMetadataModels,
13
+ } from '../shared/model-utils.js';
9
14
  export { isListWrapperModel, isListMetadataModel };
10
15
 
11
16
  /**
@@ -83,12 +88,22 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
83
88
  lines.push('');
84
89
 
85
90
  const requestBodyOnly = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
91
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
92
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
93
+ // code references them by name and the pagination iterator doesn't unwrap them.
94
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
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);
86
101
 
87
102
  // Build structural hash for deduplication
88
103
  const modelHashMap = new Map<string, string>();
89
104
  const hashGroups = new Map<string, string[]>();
90
105
  for (const model of models) {
91
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
106
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
92
107
  if (requestBodyOnly.has(model.name)) continue;
93
108
  const hash = structuralHash(model);
94
109
  modelHashMap.set(model.name, hash);
@@ -112,7 +127,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
112
127
 
113
128
  const batchedAliases = new Set<string>();
114
129
  for (const model of models) {
115
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
130
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
116
131
  if (requestBodyOnly.has(model.name)) continue;
117
132
 
118
133
  const structName = className(model.name);
@@ -2,7 +2,12 @@ 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
+ collectReferencedListMetadataModels,
10
+ } from '../shared/model-utils.js';
6
11
 
7
12
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
8
13
  const MODELS_PACKAGE = 'com.workos.models';
@@ -47,7 +52,7 @@ function promoteFieldType(f: Field): Field {
47
52
  * List wrappers (`{ data, list_metadata }`) and the shared `ListMetadata`
48
53
  * model are skipped — the hand-maintained runtime provides [Page]/[ListMetadata].
49
54
  */
50
- export function generateModels(models: Model[], _ctx: EmitterContext): GeneratedFile[] {
55
+ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
51
56
  if (models.length === 0) return [];
52
57
 
53
58
  // First pass: call mapTypeRef on every model field so discriminator info is
@@ -58,12 +63,23 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
58
63
 
59
64
  const files: GeneratedFile[] = [];
60
65
 
66
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
67
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
68
+ // code references them by name and pagination iterators don't unwrap them.
69
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
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);
76
+
61
77
  // Deduplication: identical structures become typealiases.
62
78
  // Pass 1: hash without nested-alias resolution.
63
79
  modelAliasMap = null;
64
80
  const hashGroupsPass1 = new Map<string, string[]>();
65
81
  for (const model of models) {
66
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
82
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
67
83
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
68
84
  const hash = structuralHash(model);
69
85
  if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
@@ -86,7 +102,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
86
102
  modelAliasMap = aliasOf;
87
103
  const hashGroupsPass2 = new Map<string, string[]>();
88
104
  for (const model of models) {
89
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
105
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
90
106
  if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
91
107
  if (aliasOf.has(model.name)) continue; // already aliased in pass 1
92
108
  const hash = structuralHash(model);
@@ -105,7 +121,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
105
121
  }
106
122
 
107
123
  for (const model of models) {
108
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
124
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
109
125
  const typeName = className(model.name);
110
126
 
111
127
  // Parent of a discriminated union: emit a sealed class.
@@ -142,7 +158,7 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
142
158
  // mapping so Jackson can deserialize directly to the correct concrete type.
143
159
  const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
144
160
  for (const model of models) {
145
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
161
+ if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
146
162
  if (aliasOf.has(model.name)) continue;
147
163
  if (!isEventEnvelopeModel(model)) continue;
148
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);
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';
@@ -38,6 +39,24 @@ import { fileName } from './naming.js';
38
39
  */
39
40
  const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
40
41
 
42
+ /**
43
+ * Paths the node emitter has produced so far in this ctx, accumulated across
44
+ * `applyLiveSurface` calls. Drives `carryForwardManagedFiles` so files in the
45
+ * prior manifest that we did not re-emit this run still land in the new
46
+ * manifest as "still managed" — without that, the orchestrator's prune diff
47
+ * treats every untouched autogen file as stale.
48
+ */
49
+ const emittedPathsCache = new WeakMap<EmitterContext, Set<string>>();
50
+
51
+ function getEmittedPaths(ctx: EmitterContext): Set<string> {
52
+ let set = emittedPathsCache.get(ctx);
53
+ if (!set) {
54
+ set = new Set();
55
+ emittedPathsCache.set(ctx, set);
56
+ }
57
+ return set;
58
+ }
59
+
41
60
  function getSurface(ctx: EmitterContext): LiveSurface {
42
61
  let surface = surfaceCache.get(ctx);
43
62
  if (surface) return surface;
@@ -90,6 +109,19 @@ function getSurface(ctx: EmitterContext): LiveSurface {
90
109
  // Pass an empty map; type-map will fall back to emitting the symbol name.
91
110
  setInlineEnumUnions(new Map());
92
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
+
93
125
  return surface;
94
126
  }
95
127
 
@@ -115,8 +147,10 @@ function getSurface(ctx: EmitterContext): LiveSurface {
115
147
  * `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
116
148
  * with no `--target` step they would otherwise land as untracked cruft.
117
149
  *
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`.
150
+ * Note: the carry-forward step in `generateTests` re-declares prior-manifest
151
+ * paths we didn't touch this run, so the orchestrator's prune diff stays
152
+ * accurate without needing `--no-prune` at the call site. See
153
+ * `carryForwardManagedFiles` below.
120
154
  */
121
155
  /**
122
156
  * `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
@@ -355,6 +389,55 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
355
389
  }
356
390
  out.push(f);
357
391
  }
392
+ const emitted = getEmittedPaths(ctx);
393
+ for (const f of out) emitted.add(f.path);
394
+ return out;
395
+ }
396
+
397
+ /**
398
+ * Re-declare prior-manifest paths that we did not emit this run so manifest
399
+ * pruning can tell "intentionally removed" from "untouched but still managed."
400
+ *
401
+ * The node emitter only outputs files it actually wants to write each run —
402
+ * untouched-but-up-to-date autogen files don't come back through any
403
+ * `generateXxx` method. Without this carry-forward, the orchestrator's
404
+ * `prevManifest.files − currentEmission` diff treats every such file as stale
405
+ * and prunes the whole tree on a regen. That's why `scripts/sdk-generate.sh`
406
+ * historically paired the node emitter with `--no-prune` — at the cost of
407
+ * never pruning legitimately-removed files (e.g. an enum file orphaned by a
408
+ * `schemaNameTransform` rename like `RadarAction` → `RadarListAction`).
409
+ *
410
+ * The carry-forward entry uses `skipIfExists: true`, so writer.ts skips the
411
+ * write and only ensures the header is present (no-op for files that already
412
+ * have it). The path still lands in `outputEmittedPaths` and therefore in the
413
+ * new manifest, which restores correct prune semantics.
414
+ *
415
+ * Files dropped from the carry-forward set:
416
+ * - Not on disk anymore (file was hand-deleted — let prune confirm absence).
417
+ * - `@oagen-ignore-file` protected (user has explicitly taken ownership).
418
+ * - `.ts` files that no longer carry the auto-gen header (user has taken
419
+ * ownership in-place; the next prune cycle will clear the manifest entry).
420
+ */
421
+ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
422
+ const priorPaths = ctx.priorTargetManifestPaths;
423
+ if (!priorPaths || priorPaths.size === 0) return [];
424
+
425
+ const emitted = getEmittedPaths(ctx);
426
+ const out: GeneratedFile[] = [];
427
+ for (const relPath of priorPaths) {
428
+ if (emitted.has(relPath)) continue;
429
+ if (!surface.files.has(relPath)) continue;
430
+ if (surface.protectedFiles.has(relPath)) continue;
431
+ if (relPath.endsWith('.ts') && !surface.autogenFiles.has(relPath)) continue;
432
+
433
+ out.push({
434
+ path: relPath,
435
+ content: '',
436
+ skipIfExists: true,
437
+ headerPlacement: 'skip',
438
+ });
439
+ emitted.add(relPath);
440
+ }
358
441
  return out;
359
442
  }
360
443
 
@@ -440,11 +523,16 @@ export const nodeEmitter: Emitter = {
440
523
 
441
524
  // Test specs and fixtures are hand-maintained except for explicitly-owned
442
525
  // service directories.
526
+ //
527
+ // This is also the last `generateXxx` hook in `generateAllFiles`, so it's
528
+ // where we tack on the carry-forward set — see `carryForwardManagedFiles`.
443
529
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
444
530
  const nodeCtx = withNodeOperationOverrides(ctx);
445
- if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
446
531
  const surface = getSurface(nodeCtx);
447
- return applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface);
532
+ const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
533
+ ? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
534
+ : [];
535
+ return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
448
536
  },
449
537
 
450
538
  // No operations map needed — the manifest belongs to the staging+target flow,
@@ -23,6 +23,8 @@ import {
23
23
  createServiceDirResolver,
24
24
  isListMetadataModel,
25
25
  isListWrapperModel,
26
+ collectNonPaginatedResponseModelNames,
27
+ collectReferencedListMetadataModels,
26
28
  buildDeduplicationMap,
27
29
  relativeImport,
28
30
  modelHasNewFields,
@@ -209,12 +211,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
209
211
  }
210
212
 
211
213
  const discriminatedSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
214
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
215
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
216
+ // code references them by name and pagination iterators don't unwrap them.
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
+
212
226
  for (const originalModel of models) {
213
227
  const model = projectedByName.get(originalModel.name) ?? originalModel;
214
228
  if (!reachableModels.has(model.name)) continue;
215
229
  if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
216
- if (isListMetadataModel(model)) continue;
217
- if (isListWrapperModel(model)) continue;
230
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
231
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
218
232
  if (discriminatedSkip?.has(model.name)) continue;
219
233
  const service = modelToService.get(model.name);
220
234
  const isOwnedModel = isNodeOwnedService(ctx, service);
@@ -512,36 +526,43 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
512
526
  }
513
527
  lines.push('');
514
528
 
515
- // Wire/response interface
516
- const seenWireFields = new Set<string>();
517
- if (model.fields.length === 0) {
518
- lines.push(`export type ${responseName}${typeParams} = object;`);
519
- } else {
520
- lines.push(`export interface ${responseName}${typeParams} {`);
521
- for (const field of model.fields) {
522
- const wireField = wireFieldName(field.name);
523
- if (seenWireFields.has(wireField)) continue;
524
- seenWireFields.add(wireField);
525
- const baselineField = baselineResponse?.fields?.[wireField];
526
- if (
527
- baselineField &&
528
- baselineTypeResolvable(baselineField.type, importableNames) &&
529
- baselineFieldCompatible(baselineField, field)
530
- ) {
531
- const opt = baselineField.optional ? '?' : '';
532
- lines.push(` ${wireField}${opt}: ${baselineField.type};`);
533
- } else {
534
- const isNewFieldOnExistingModel = baselineResponse && !baselineField;
535
- // Same baseline-optional preservation as the domain side. The
536
- // wire interface's optional flag drives test-fixture shape, so
537
- // flipping it on regen breaks every fixture that omitted the
538
- // field assuming it was optional.
539
- const baselineSaysOptional = baselineField?.optional === true;
540
- const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
541
- 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
+ }
542
563
  }
564
+ lines.push('}');
543
565
  }
544
- lines.push('}');
545
566
  }
546
567
 
547
568
  // Preserve inline types from existing file
@@ -733,13 +754,18 @@ export function generateSerializers(
733
754
  }
734
755
 
735
756
  const discriminatedSerializerSkip = (ctx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames;
757
+ const serializerNonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
758
+
759
+ // Mirror the interface-emission gate (see `generateModels`).
760
+ const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
761
+
736
762
  const eligibleModels: Model[] = [];
737
763
  for (const originalModel of models) {
738
764
  const model = projectedByName.get(originalModel.name) ?? originalModel;
739
765
  if (!serializerReachable.has(model.name)) continue;
740
766
  if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
741
- if (isListMetadataModel(model)) continue;
742
- if (isListWrapperModel(model)) continue;
767
+ if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
768
+ if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
743
769
  if (discriminatedSerializerSkip?.has(model.name)) continue;
744
770
  const service = modelToService.get(model.name);
745
771
  const isOwnedModel = isNodeOwnedService(ctx, service);