@workos/oagen-emitters 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-BGVaMGqe.mjs → plugin-CO4RFgAW.mjs} +959 -251
  6. package/dist/plugin-CO4RFgAW.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +7 -7
  9. package/renovate.json +1 -61
  10. package/src/go/client.ts +1 -1
  11. package/src/go/enums.ts +77 -0
  12. package/src/kotlin/enums.ts +11 -4
  13. package/src/node/client.ts +119 -2
  14. package/src/node/discriminated-models.ts +8 -0
  15. package/src/node/field-plan.ts +64 -8
  16. package/src/node/index.ts +59 -3
  17. package/src/node/models.ts +73 -30
  18. package/src/node/naming.ts +14 -1
  19. package/src/node/node-overrides.ts +4 -37
  20. package/src/node/options.ts +29 -1
  21. package/src/node/resources.ts +533 -83
  22. package/src/node/tests.ts +108 -7
  23. package/src/php/fixtures.ts +4 -1
  24. package/src/php/models.ts +3 -1
  25. package/src/php/resources.ts +40 -11
  26. package/src/php/tests.ts +22 -12
  27. package/src/python/client.ts +0 -8
  28. package/src/python/enums.ts +41 -15
  29. package/src/python/fixtures.ts +23 -7
  30. package/src/python/models.ts +26 -5
  31. package/src/python/resources.ts +71 -3
  32. package/src/python/tests.ts +70 -12
  33. package/src/python/wrappers.ts +25 -4
  34. package/src/ruby/client.ts +0 -1
  35. package/src/rust/resources.ts +10 -7
  36. package/src/shared/non-spec-services.ts +0 -5
  37. package/test/go/enums.test.ts +24 -0
  38. package/test/node/resources.test.ts +11 -1
  39. package/test/node/tests.test.ts +3 -3
  40. package/test/php/client.test.ts +0 -1
  41. package/test/php/resources.test.ts +50 -0
  42. package/test/rust/resources.test.ts +9 -0
  43. package/dist/plugin-BGVaMGqe.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BGVaMGqe.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-CO4RFgAW.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.4",
3
+ "version": "0.15.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -38,22 +38,22 @@
38
38
  "prepare": "husky"
39
39
  },
40
40
  "devDependencies": {
41
- "@commitlint/cli": "^21.0.1",
42
- "@commitlint/config-conventional": "^21.0.1",
41
+ "@commitlint/cli": "^21.0.2",
42
+ "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^25.9.1",
44
44
  "husky": "^9.1.7",
45
45
  "oxfmt": "^0.52.0",
46
46
  "oxlint": "^1.67.0",
47
47
  "prettier": "^3.8.3",
48
- "tsdown": "^0.22.0",
49
- "tsx": "^4.22.3",
48
+ "tsdown": "^0.22.1",
49
+ "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.7"
51
+ "vitest": "^4.1.8"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.21.0"
57
+ "@workos/oagen": "^0.21.1"
58
58
  }
59
59
  }
package/renovate.json CHANGED
@@ -1,66 +1,6 @@
1
1
  {
2
2
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
3
  "extends": [
4
- "github>workos/renovate-config"
5
- ],
6
- "dependencyDashboard": false,
7
- "schedule": [
8
- "on the 15th day of the month before 12pm"
9
- ],
10
- "timezone": "UTC",
11
- "rebaseWhen": "conflicted",
12
- "packageRules": [
13
- {
14
- "matchManagers": [
15
- "github-actions"
16
- ],
17
- "pinDigests": true,
18
- "extractVersion": "^v(?<version>\\d+\\.\\d+\\.\\d+)$"
19
- },
20
- {
21
- "matchUpdateTypes": [
22
- "minor",
23
- "patch"
24
- ],
25
- "automerge": true,
26
- "groupName": "minor and patch updates"
27
- },
28
- {
29
- "matchUpdateTypes": [
30
- "major"
31
- ],
32
- "automerge": false
33
- },
34
- {
35
- "matchUpdateTypes": [
36
- "digest"
37
- ],
38
- "automerge": false
39
- },
40
- {
41
- "matchManagers": [
42
- "github-actions"
43
- ],
44
- "matchUpdateTypes": [
45
- "minor",
46
- "patch",
47
- "digest",
48
- "pinDigest"
49
- ],
50
- "groupName": "github actions non-major",
51
- "groupSlug": "github-actions-non-major",
52
- "automerge": true
53
- },
54
- {
55
- "matchManagers": [
56
- "github-actions"
57
- ],
58
- "matchUpdateTypes": [
59
- "major"
60
- ],
61
- "groupName": "github actions major",
62
- "groupSlug": "github-actions-major",
63
- "automerge": false
64
- }
4
+ "github>workos/renovate-config:public"
65
5
  ]
66
6
  }
package/src/go/client.ts CHANGED
@@ -17,7 +17,7 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
17
17
  }
18
18
 
19
19
  /**
20
- * Non-spec services marked with `hasClientAccessor: true` (passwordless, vault)
20
+ * Non-spec services marked with `hasClientAccessor: true` (e.g. passwordless)
21
21
  * are included in the generated Client struct, constructor, and accessor methods
22
22
  * — identical to spec-driven services. Their service type (e.g. PasswordlessService)
23
23
  * is defined in a hand-written @oagen-ignore-file, but the Client wiring is generated.
package/src/go/enums.ts CHANGED
@@ -96,6 +96,8 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
96
  content: lines.join('\n'),
97
97
  overwriteExisting: true,
98
98
  });
99
+ const eventConstantsFile = generateEventConstantsFile(enums);
100
+ if (eventConstantsFile) files.push(eventConstantsFile);
99
101
 
100
102
  return files;
101
103
  }
@@ -173,6 +175,81 @@ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
173
175
  return aliasOf;
174
176
  }
175
177
 
178
+ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
179
+ const enumDef = findWebhookEventEnum(enums);
180
+ if (!enumDef) return null;
181
+
182
+ const lines: string[] = [];
183
+ lines.push('package events');
184
+ lines.push('');
185
+ lines.push('// Event is a WorkOS event type.');
186
+ lines.push('type Event string');
187
+ lines.push('');
188
+ lines.push('const (');
189
+
190
+ const seenValues = new Set<string>();
191
+ const usedNames = new Set<string>();
192
+ for (const value of enumDef.values) {
193
+ const valueStr = String(value.value);
194
+ if (seenValues.has(valueStr)) continue;
195
+ seenValues.add(valueStr);
196
+
197
+ const constName = uniqueEventConstantName(valueStr, usedNames);
198
+ usedNames.add(constName);
199
+
200
+ if (value.description) {
201
+ lines.push(`\t// ${constName} is ${value.description}.`);
202
+ }
203
+ if (value.deprecated) {
204
+ if (value.description) lines.push('\t//');
205
+ lines.push('\t// Deprecated: this value is deprecated.');
206
+ }
207
+ // Keep constants untyped so callers can use them as plain strings,
208
+ // events.Event values, or typed root-package enum values.
209
+ lines.push(`\t${constName} = "${escapeGoString(valueStr)}"`);
210
+ }
211
+
212
+ lines.push(')');
213
+ lines.push('');
214
+
215
+ return {
216
+ path: 'pkg/events/events.go',
217
+ content: lines.join('\n'),
218
+ overwriteExisting: true,
219
+ };
220
+ }
221
+
222
+ function findWebhookEventEnum(enums: Enum[]): Enum | null {
223
+ return (
224
+ enums.find((enumDef) => enumDef.name === 'CreateWebhookEndpointEvents') ??
225
+ enums.find(
226
+ (enumDef) =>
227
+ isWebhookEventEnumName(enumDef.name) &&
228
+ enumDef.values.length > 0 &&
229
+ enumDef.values.every((value) => typeof value.value === 'string' && value.value.includes('.')),
230
+ ) ??
231
+ null
232
+ );
233
+ }
234
+
235
+ function isWebhookEventEnumName(name: string): boolean {
236
+ const normalized = name.toLowerCase();
237
+ return normalized.includes('webhook') && normalized.includes('event');
238
+ }
239
+
240
+ function uniqueEventConstantName(value: string, usedNames: Set<string>): string {
241
+ const base = className(value);
242
+ if (!usedNames.has(base)) return base;
243
+
244
+ let suffix = 2;
245
+ while (usedNames.has(`${base}${suffix}`)) suffix++;
246
+ return `${base}${suffix}`;
247
+ }
248
+
249
+ function escapeGoString(value: string): string {
250
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
251
+ }
252
+
176
253
  export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
177
254
  const enumToService = new Map<string, string>();
178
255
  const enumNames = new Set(enums.map((e) => e.name));
@@ -41,12 +41,19 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
41
41
 
42
42
  // Within each group, pick the shortest className as canonical.
43
43
  const aliasOf = new Map<string, string>(); // enum name → canonical enum name
44
+ const sharedSortEmitters = new Set<string>();
44
45
  for (const [, group] of hashGroups) {
45
- if (group.length <= 1) continue;
46
+ if (group.length <= 1) {
47
+ if (group.length === 1 && isSharedSortOrderEnum(group[0])) {
48
+ enumCanonicalMap.set(group[0].name, 'SortOrder');
49
+ sharedSortEmitters.add(group[0].name);
50
+ }
51
+ continue;
52
+ }
46
53
  if (group.every(isSharedSortOrderEnum)) {
47
54
  const [canonical, ...rest] = [...group].sort((a, b) => a.name.localeCompare(b.name));
48
- enumCanonicalMap.set(canonical.name, canonical.name);
49
- for (const enumDef of rest) enumCanonicalMap.set(enumDef.name, 'SortOrder');
55
+ sharedSortEmitters.add(canonical.name);
56
+ for (const enumDef of [canonical, ...rest]) enumCanonicalMap.set(enumDef.name, 'SortOrder');
50
57
  continue;
51
58
  }
52
59
  const sorted = [...group].sort(
@@ -68,7 +75,7 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
68
75
  const typeName = canonicalEnumTypeName(enumDef);
69
76
 
70
77
  // Non-canonical enum: emit a typealias instead of a full enum class.
71
- const sharedSortEmitter = isSharedSortOrderEnum(enumDef) && enumCanonicalMap.get(enumDef.name) === enumDef.name;
78
+ const sharedSortEmitter = sharedSortEmitters.has(enumDef.name);
72
79
  const canonicalName = sharedSortEmitter
73
80
  ? undefined
74
81
  : (aliasOf.get(enumDef.name) ?? enumCanonicalMap.get(enumDef.name));
@@ -18,9 +18,11 @@ import {
18
18
  isListWrapperModel,
19
19
  computeNonEventReachable,
20
20
  } from './utils.js';
21
+ import { isNodeOwnedService, nodeOptions } from './options.js';
21
22
  import { resolveResourceClassName, resolveResourceDir } from './resources.js';
22
23
  import { generatedResourceInterfaceModelNames } from './models.js';
23
24
  import { assignEnumsToServices } from './enums.js';
25
+ import { liveSurfaceInterfacePath } from './live-surface.js';
24
26
 
25
27
  export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
26
28
  const files: GeneratedFile[] = [];
@@ -129,6 +131,46 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
129
131
  };
130
132
  }
131
133
 
134
+ function sourceFileForExportedType(ctx: EmitterContext, typeName: string): string | undefined {
135
+ return (
136
+ (ctx.apiSurface?.interfaces?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
137
+ (ctx.apiSurface?.typeAliases?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
138
+ (ctx.apiSurface?.enums?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
139
+ liveSurfaceInterfacePath(typeName)
140
+ );
141
+ }
142
+
143
+ function exportedNamesForSource(ctx: EmitterContext, sourceFile: string): string[] {
144
+ const names = new Set<string>();
145
+ const addNamesFromSurface = (items: Record<string, unknown> | undefined) => {
146
+ if (!items) return;
147
+ for (const [name, item] of Object.entries(items)) {
148
+ if ((item as { sourceFile?: string }).sourceFile === sourceFile) {
149
+ names.add(name);
150
+ }
151
+ }
152
+ };
153
+
154
+ addNamesFromSurface(ctx.apiSurface?.interfaces);
155
+ addNamesFromSurface(ctx.apiSurface?.typeAliases);
156
+ addNamesFromSurface(ctx.apiSurface?.enums);
157
+
158
+ const rootDir = ctx.targetDir ?? ctx.outputDir;
159
+ if (rootDir) {
160
+ try {
161
+ const content = fs.readFileSync(path.join(rootDir, sourceFile), 'utf-8');
162
+ for (const match of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
163
+ names.add(match[1]);
164
+ }
165
+ } catch {
166
+ // The live source map is best-effort; apiSurface/livesurface names above
167
+ // are enough when the file is not readable.
168
+ }
169
+ }
170
+
171
+ return [...names];
172
+ }
173
+
132
174
  /**
133
175
  * Generate per-service barrel files (interfaces/index.ts) that re-export
134
176
  * all interface and enum files for each service directory. This reduces
@@ -146,6 +188,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
146
188
  // from one file and a domain type from another).
147
189
  const dirExports = new Map<string, string[]>();
148
190
  const dirSymbols = new Map<string, Set<string>>();
191
+ const ownedDirNames = new Set<string>();
192
+ for (const service of spec.services) {
193
+ if (isNodeOwnedService(ctx, service.name)) {
194
+ ownedDirNames.add(resolveDir(service.name));
195
+ }
196
+ }
149
197
 
150
198
  // Pre-seed dirSymbols with names already exported by existing interface files.
151
199
  // When the existing SDK has an interface file in a directory that already
@@ -160,6 +208,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
160
208
  const match = sourceFile.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
161
209
  if (!match) return;
162
210
  const dirName = match[1];
211
+ if (ownedDirNames.has(dirName)) return;
163
212
  const fileStem = match[2];
164
213
  if (!dirSymbols.has(dirName)) dirSymbols.set(dirName, new Set());
165
214
  dirSymbols.get(dirName)!.add(name);
@@ -260,14 +309,82 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
260
309
  dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
261
310
  }
262
311
 
312
+ const overrideWildcardSources = new Set<string>();
313
+ const overrideNamedExports = new Set<string>();
314
+ const addOverrideTypeExport = (typeName: string | undefined) => {
315
+ if (!typeName) return;
316
+ const sourceFile = sourceFileForExportedType(ctx, typeName);
317
+ if (!sourceFile) return;
318
+ const match = sourceFile?.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
319
+ if (!match) return;
320
+ const dirName = match[1];
321
+ const stem = match[2].replace(/\.ts$/, '');
322
+ if (!dirExports.has(dirName)) {
323
+ dirExports.set(dirName, []);
324
+ if (!dirSymbols.has(dirName)) {
325
+ dirSymbols.set(dirName, new Set());
326
+ }
327
+ }
328
+ const symbols = dirSymbols.get(dirName)!;
329
+ const sourceKey = `${dirName}/${stem}`;
330
+ if (overrideWildcardSources.has(sourceKey)) return;
331
+
332
+ const sourceNames = exportedNamesForSource(ctx, sourceFile);
333
+ const hasConflictingNeighbor = sourceNames.some((name) => name !== typeName && globalExistingSymbols.has(name));
334
+ if (hasConflictingNeighbor) {
335
+ const exportKey = `${sourceKey}:${typeName}`;
336
+ if (overrideNamedExports.has(exportKey)) return;
337
+ dirExports.get(dirName)!.push(`export type { ${typeName} } from './${stem}';`);
338
+ overrideNamedExports.add(exportKey);
339
+ symbols.add(typeName);
340
+ globalExistingSymbols.add(typeName);
341
+ return;
342
+ }
343
+
344
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
345
+ overrideWildcardSources.add(sourceKey);
346
+ const namesToRegister = sourceNames.length > 0 ? sourceNames : [typeName];
347
+ for (const name of namesToRegister) {
348
+ symbols.add(name);
349
+ globalExistingSymbols.add(name);
350
+ }
351
+ };
352
+
353
+ for (const override of Object.values(nodeOptions(ctx).operationOverrides ?? {})) {
354
+ addOverrideTypeExport(override.optionsType);
355
+ for (const typeName of override.returnTypeImports ?? []) {
356
+ addOverrideTypeExport(typeName);
357
+ }
358
+ }
359
+
360
+ const generatedOptionExports = (ctx as any)._nodeGeneratedOptionInterfaceExports as
361
+ | Map<string, Array<{ stem: string; typeName: string }>>
362
+ | undefined;
363
+ for (const [dirName, options] of generatedOptionExports ?? []) {
364
+ if (!dirExports.has(dirName)) {
365
+ dirExports.set(dirName, []);
366
+ if (!dirSymbols.has(dirName)) {
367
+ dirSymbols.set(dirName, new Set());
368
+ }
369
+ }
370
+ const symbols = dirSymbols.get(dirName)!;
371
+ for (const { stem, typeName } of options) {
372
+ if (globalExistingSymbols.has(typeName)) continue;
373
+ symbols.add(typeName);
374
+ globalExistingSymbols.add(typeName);
375
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
376
+ }
377
+ }
378
+
263
379
  for (const [dirName, exports] of dirExports) {
264
380
  const exportSet = new Set(exports);
381
+ const isDirOwned = ownedDirNames.has(dirName);
265
382
 
266
383
  // When integrating into an existing SDK, include baseline exports from
267
384
  // the api-surface so the barrel is comprehensive. This ensures stale
268
385
  // entries (e.g., renamed files from previous generations) are removed
269
386
  // when overwriteExisting replaces the barrel.
270
- if (ctx.apiSurface) {
387
+ if (ctx.apiSurface && !isDirOwned) {
271
388
  const addBaselineExports = (items: Record<string, any> | undefined) => {
272
389
  if (!items) return;
273
390
  for (const item of Object.values(items)) {
@@ -288,7 +405,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
288
405
  // corresponding file still exists on disk. This prevents dropping
289
406
  // hand-written types (e.g., Factor in multi-factor-auth) when a
290
407
  // generated model in the same file causes a symbol collision.
291
- if (ctx.targetDir) {
408
+ if (ctx.targetDir && !isDirOwned) {
292
409
  const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
293
410
  try {
294
411
  const barrelPath = path.join(interfacesDir, 'index.ts');
@@ -69,6 +69,8 @@ interface DiscriminatedShape {
69
69
  discriminatorProperty: string;
70
70
  /** Field name in domain (camelCase). */
71
71
  discriminatorPropertyDomain: string;
72
+ /** Description from the OpenAPI spec, if present. */
73
+ discriminatorDescription?: string;
72
74
  variants: VariantSpec[];
73
75
  }
74
76
 
@@ -133,11 +135,14 @@ export function detectDiscriminatedShape(
133
135
 
134
136
  const baseFields = baseObject ? collectObjectFields(baseObject, modelName) : [];
135
137
 
138
+ const discriminatorDescription = flattenedVariants[0].alwaysProperties.get(discProp)?.description;
139
+
136
140
  return {
137
141
  modelName,
138
142
  baseFields,
139
143
  discriminatorProperty: discProp,
140
144
  discriminatorPropertyDomain: toCamelCase(discProp),
145
+ discriminatorDescription,
141
146
  variants,
142
147
  };
143
148
  }
@@ -527,6 +532,9 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
527
532
  }
528
533
  // Discriminator (typed as the variant's const value)
529
534
  const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
535
+ if (shape.discriminatorDescription) {
536
+ lines.push(` /** ${shape.discriminatorDescription} */`);
537
+ }
530
538
  lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
531
539
  // Variant-specific fields
532
540
  for (const field of variant.fields) {
@@ -7,8 +7,15 @@ import {
7
7
  isBaselineGeneric,
8
8
  createServiceDirResolver,
9
9
  modelHasNewFields,
10
+ assignModelsToServices,
10
11
  } from './utils.js';
11
- import { liveSurfaceHasFunction, liveSurfaceHasFile, liveSurfaceFunctionPath } from './live-surface.js';
12
+ import {
13
+ liveSurfaceHasFunction,
14
+ liveSurfaceHasFile,
15
+ liveSurfaceFunctionPath,
16
+ liveSurfaceHasAutogenFile,
17
+ } from './live-surface.js';
18
+ import { isNodeOwnedService } from './options.js';
12
19
 
13
20
  // ---------------------------------------------------------------------------
14
21
  // Guard strategy
@@ -51,6 +58,19 @@ function helperExists(helperName: string, depModelName: string, ctx: EmitterCont
51
58
  if (liveSurfaceHasFunction(helperName)) return true;
52
59
  const depModel = ctx.spec.models.find((m) => m.name === depModelName);
53
60
  if (!depModel) return false;
61
+ const modelToService = assignModelsToServices(ctx.spec.models, ctx.spec.services, ctx.modelHints);
62
+ const depService = modelToService.get(depModelName);
63
+ const resolvedName = resolveInterfaceName(depModelName, ctx);
64
+ const siblingPrefix = helperName.startsWith('serialize') ? 'deserialize' : 'serialize';
65
+ const siblingPath = liveSurfaceFunctionPath(`${siblingPrefix}${resolvedName}`);
66
+ if (siblingPath && liveSurfaceHasFile(siblingPath) && !liveSurfaceHasAutogenFile(siblingPath)) return false;
67
+ const sourceFile = (ctx.apiSurface?.interfaces?.[resolvedName] as { sourceFile?: string } | undefined)?.sourceFile;
68
+ const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
69
+ const candidate = sourceFile
70
+ ? sourceFile.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
71
+ : `src/${resolveDir(depService)}/serializers/${fileName(depModelName)}.serializer.ts`;
72
+ if (liveSurfaceHasFile(candidate) && !liveSurfaceHasAutogenFile(candidate)) return false;
73
+ if (isNodeOwnedService(ctx, depService)) return true;
54
74
  return modelHasNewFields(depModel, ctx);
55
75
  }
56
76
 
@@ -699,6 +719,7 @@ export function buildSerializerImports(
699
719
  const depService = sctx.modelToService.get(dep);
700
720
  const depDir = sctx.resolveDir(depService);
701
721
  const depName = resolveInterfaceName(dep, sctx.ctx);
722
+ const depIsOwned = isNodeOwnedService(sctx.ctx, depService);
702
723
 
703
724
  // Locate the serializer file, in priority order:
704
725
  // 1. The actual file containing `deserialize${depName}` per
@@ -708,14 +729,16 @@ export function buildSerializerImports(
708
729
  // 2. The baseline interface's adjacent serializer file path.
709
730
  // 3. The IR-name path — this is where the emitter writes the
710
731
  // serializer it's producing this run.
711
- const baselineSrc = (sctx.ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
732
+ const baselineSrc = depIsOwned
733
+ ? undefined
734
+ : (sctx.ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
712
735
  const baselineSerializerPath = baselineSrc
713
736
  ? baselineSrc.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
714
737
  : null;
715
738
  const irNameSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
716
739
 
717
- const liveDeserPath = liveSurfaceFunctionPath(`deserialize${depName}`);
718
- const liveSerPath = liveSurfaceFunctionPath(`serialize${depName}`);
740
+ const liveDeserPath = depIsOwned ? undefined : liveSurfaceFunctionPath(`deserialize${depName}`);
741
+ const liveSerPath = depIsOwned ? undefined : liveSurfaceFunctionPath(`serialize${depName}`);
719
742
  const depSerializerPath =
720
743
  liveDeserPath ??
721
744
  liveSerPath ??
@@ -742,11 +765,11 @@ export function buildSerializerImports(
742
765
  // pass-through expression when it can't call the helper.
743
766
  const hasDeser = liveSurfaceHasFunction(`deserialize${depName}`);
744
767
  const hasSer = liveSurfaceHasFunction(`serialize${depName}`);
745
- const fileExists = liveSurfaceHasFile(depSerializerPath);
768
+ const fileExists = !depIsOwned && liveSurfaceHasFile(depSerializerPath);
746
769
  if (fileExists && !hasDeser && !hasSer) continue;
747
770
  if (!fileExists) {
748
771
  const depModel = sctx.ctx.spec.models.find((m) => m.name === dep);
749
- const willGenerateSerializer = depModel ? modelHasNewFields(depModel, sctx.ctx) : true;
772
+ const willGenerateSerializer = depModel ? depIsOwned || modelHasNewFields(depModel, sctx.ctx) : true;
750
773
  if (!willGenerateSerializer) continue;
751
774
  }
752
775
 
@@ -803,7 +826,9 @@ export function shouldSkipSerializeForModel(
803
826
  skippedSerializeModels: Set<string>,
804
827
  ctx: EmitterContext,
805
828
  ): boolean {
806
- let shouldSkip = serializerHasBaselineIncompatibility(model, baselineResponse, baselineDomain, ctx);
829
+ let shouldSkip =
830
+ serializerHasBaselineIncompatibility(model, baselineResponse, baselineDomain, ctx) ||
831
+ hasUnsafeSerializePassthrough(model, baselineDomain, baselineResponse, ctx);
807
832
  if (!shouldSkip) {
808
833
  for (const field of model.fields) {
809
834
  for (const ref of collectSerializedModelRefs(field.type)) {
@@ -816,6 +841,11 @@ export function shouldSkipSerializeForModel(
816
841
  shouldSkip = true;
817
842
  break;
818
843
  }
844
+ const resolved = resolveInterfaceName(ref, ctx);
845
+ if (wireInterfaceName(resolved) !== resolved && !helperExists(`serialize${resolved}`, ref, ctx)) {
846
+ shouldSkip = true;
847
+ break;
848
+ }
819
849
  }
820
850
  if (shouldSkip) break;
821
851
  }
@@ -836,6 +866,8 @@ export function emitSerializerBody(
836
866
  ctx: EmitterContext,
837
867
  ): string[] {
838
868
  const lines: string[] = [];
869
+ const effectiveShouldSkipSerialize =
870
+ shouldSkipSerialize || hasUnsafeSerializePassthrough(model, baselineDomain, baselineResponse, ctx);
839
871
 
840
872
  if (!shouldSkipDeserialize) {
841
873
  const seenDeserFields = new Set<string>();
@@ -853,7 +885,7 @@ export function emitSerializerBody(
853
885
  lines.push('});');
854
886
  }
855
887
 
856
- if (!shouldSkipSerialize) {
888
+ if (!effectiveShouldSkipSerialize) {
857
889
  if (!shouldSkipDeserialize) lines.push('');
858
890
  const serParamPrefix = model.fields.length === 0 ? '_' : '';
859
891
  lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
@@ -872,3 +904,27 @@ export function emitSerializerBody(
872
904
 
873
905
  return lines;
874
906
  }
907
+
908
+ function hasUnsafeSerializePassthrough(
909
+ model: Model,
910
+ baselineDomain: BaselineInterface | undefined,
911
+ baselineResponse: BaselineInterface | undefined,
912
+ ctx: EmitterContext,
913
+ ): boolean {
914
+ if (!baselineDomain?.fields || !baselineResponse?.fields) return false;
915
+
916
+ for (const field of model.fields) {
917
+ const domain = fieldName(field.name);
918
+ const wire = wireFieldName(field.name);
919
+ const domainField = baselineDomain.fields[domain];
920
+ const wireField = baselineResponse.fields[wire];
921
+ if (!domainField || !wireField || domainField.type === wireField.type) continue;
922
+
923
+ const domainAccess = `model.${domain}`;
924
+ if (serializeExpression(field.type, domainAccess, ctx) === domainAccess) {
925
+ return true;
926
+ }
927
+ }
928
+
929
+ return false;
930
+ }