@workos/oagen-emitters 0.17.0 → 0.18.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-BLnR-FMi.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-CtU_wbid.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -42,18 +42,18 @@
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^25.9.3",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.54.0",
46
- "oxlint": "^1.69.0",
45
+ "oxfmt": "^0.55.0",
46
+ "oxlint": "^1.70.0",
47
47
  "prettier": "^3.8.4",
48
- "tsdown": "^0.22.2",
48
+ "tsdown": "^0.22.3",
49
49
  "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.8"
51
+ "vitest": "^4.1.9"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.5"
57
+ "@workos/oagen": "^0.22.6"
58
58
  }
59
59
  }
@@ -179,7 +179,7 @@ function exportedNamesForSource(ctx: EmitterContext, sourceFile: string): string
179
179
  */
180
180
  function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
181
181
  const files: GeneratedFile[] = [];
182
- const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
182
+ const { modelToService, resolveDir, serviceNameMap } = createServiceDirResolver(spec.models, spec.services, ctx);
183
183
  const enumToService = assignEnumsToServices(spec.enums, spec.services, spec.models, ctx);
184
184
 
185
185
  // Group interface files by directory, tracking exported symbol names
@@ -190,7 +190,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
190
190
  const dirSymbols = new Map<string, Set<string>>();
191
191
  const ownedDirNames = new Set<string>();
192
192
  for (const service of spec.services) {
193
- if (isNodeOwnedService(ctx, service.name)) {
193
+ if (isNodeOwnedService(ctx, service.name, serviceNameMap.get(service.name))) {
194
194
  const dir = resolveDir(service.name);
195
195
  ownedDirNames.add(dir);
196
196
  // Ensure owned directories always get a barrel entry, even if no
@@ -50,13 +50,17 @@ interface FieldSpec {
50
50
  modelDeps: Set<string>;
51
51
  /** Whether the field requires date parsing/formatting (format: date-time). */
52
52
  hasDateTime: boolean;
53
+ /** Inline string-enum values, rendered as a literal union (e.g. `'a' | 'b'`). */
54
+ enumValues?: string[];
53
55
  }
54
56
 
55
57
  interface VariantSpec {
56
58
  /** Domain interface name suffix, e.g. `OAuth`, `M2M`. */
57
59
  nameSuffix: string;
58
- /** Discriminator string value, e.g. `oauth`, `m2m`. */
60
+ /** Discriminator value, e.g. `oauth`, `m2m`, or `true`/`false`. */
59
61
  discriminatorValue: string;
62
+ /** Whether the discriminator value is a boolean literal (emit unquoted). */
63
+ discriminatorIsBoolean?: boolean;
60
64
  /** Fields specific to this variant (excluding the discriminator). */
61
65
  fields: FieldSpec[];
62
66
  }
@@ -72,6 +76,13 @@ interface DiscriminatedShape {
72
76
  /** Description from the OpenAPI spec, if present. */
73
77
  discriminatorDescription?: string;
74
78
  variants: VariantSpec[];
79
+ /**
80
+ * Set for pure `oneOf` schemas (no `allOf` base wrapper). These are emitted
81
+ * as an inline anonymous union (`type X = { … } | { … }`) rather than as a
82
+ * set of named variant interfaces, which keeps two-variant unions — e.g. the
83
+ * boolean-discriminated `active: true | false` token response — readable.
84
+ */
85
+ inlineUnion?: boolean;
75
86
  }
76
87
 
77
88
  // ---------------------------------------------------------------------------
@@ -83,25 +94,45 @@ export function detectDiscriminatedShape(
83
94
  rawSchemas: Record<string, RawSchema>,
84
95
  ): DiscriminatedShape | null {
85
96
  const schema = rawSchemas[modelName];
86
- if (!schema?.allOf) return null;
97
+ if (!schema) return null;
87
98
 
88
- // The expected shape: allOf contains exactly one base object and one oneOf
89
- // wrapper. The base contributes shared fields; the oneOf contributes
90
- // variant-specific fields.
91
99
  let baseObject: RawSchema | null = null;
92
100
  let oneOfVariants: RawSchema[] | null = null;
93
- for (const member of schema.allOf) {
94
- const resolved = resolveRef(member, rawSchemas);
95
- if (resolved.oneOf) {
96
- if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
97
- oneOfVariants = resolved.oneOf;
98
- } else if (resolved.properties) {
99
- baseObject = mergeBase(baseObject, resolved);
100
- } else if (resolved.allOf) {
101
- // Nested allOf at top: walk it
102
- const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
103
- baseObject = mergeBase(baseObject, nestedBase);
101
+ let inlineUnion = false;
102
+
103
+ if (schema.allOf) {
104
+ // `allOf [base, oneOf [variant, …]]`: the base contributes shared fields;
105
+ // the oneOf contributes variant-specific fields. Emitted as named variant
106
+ // interfaces (one per variant) plus a union alias.
107
+ for (const member of schema.allOf) {
108
+ const resolved = resolveRef(member, rawSchemas);
109
+ if (resolved.oneOf) {
110
+ if (oneOfVariants) return null; // unexpected: multiple oneOf branches at top
111
+ oneOfVariants = resolved.oneOf;
112
+ } else if (resolved.properties) {
113
+ baseObject = mergeBase(baseObject, resolved);
114
+ } else if (resolved.allOf) {
115
+ // Nested allOf at top: walk it
116
+ const nestedBase = flattenObjectAllOf(resolved, rawSchemas);
117
+ baseObject = mergeBase(baseObject, nestedBase);
118
+ }
104
119
  }
120
+ } else if (schema.oneOf && schema.oneOf.length >= 2) {
121
+ // Pure `oneOf` (no base wrapper): every branch must be a plain inline
122
+ // object so a shared const discriminator can tell them apart. This is the
123
+ // discriminated-union shape the parser would otherwise flatten into a
124
+ // single all-optional interface (e.g. the boolean-discriminated token
125
+ // response `{ active: true; … } | { active: false; … }`). The
126
+ // mutually-exclusive-field-group oneOf (no shared discriminator) is left
127
+ // alone by the `findSharedDiscriminator` check below.
128
+ const allInlineObjects = schema.oneOf.every(
129
+ (v) => v.properties && !v.$ref && (v.type === 'object' || !v.type) && !v.allOf && !v.oneOf,
130
+ );
131
+ if (!allInlineObjects) return null;
132
+ oneOfVariants = schema.oneOf;
133
+ inlineUnion = true;
134
+ } else {
135
+ return null;
105
136
  }
106
137
  if (!oneOfVariants || oneOfVariants.length < 2) return null;
107
138
 
@@ -120,12 +151,13 @@ export function detectDiscriminatedShape(
120
151
 
121
152
  // Build variant specs.
122
153
  const variants: VariantSpec[] = flattenedVariants
123
- .map((fv) => {
124
- const discValue = readConstString(fv.alwaysProperties.get(discProp));
125
- if (!discValue) return null;
154
+ .map((fv): VariantSpec | null => {
155
+ const discValue = readConst(fv.alwaysProperties.get(discProp));
156
+ if (discValue === null) return null;
126
157
  return {
127
- nameSuffix: variantNameSuffix(discValue),
128
- discriminatorValue: discValue,
158
+ nameSuffix: variantNameSuffix(String(discValue)),
159
+ discriminatorValue: String(discValue),
160
+ discriminatorIsBoolean: typeof discValue === 'boolean',
129
161
  fields: variantFields(fv, discProp, modelName),
130
162
  };
131
163
  })
@@ -144,6 +176,7 @@ export function detectDiscriminatedShape(
144
176
  discriminatorPropertyDomain: toCamelCase(discProp),
145
177
  discriminatorDescription,
146
178
  variants,
179
+ inlineUnion,
147
180
  };
148
181
  }
149
182
 
@@ -302,12 +335,12 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
302
335
  const values: string[] = [];
303
336
  for (const v of variants) {
304
337
  const schema = v.alwaysProperties.get(propName);
305
- const val = readConstString(schema);
338
+ const val = readConst(schema);
306
339
  if (val === null) {
307
340
  allConst = false;
308
341
  break;
309
342
  }
310
- values.push(val);
343
+ values.push(String(val));
311
344
  }
312
345
  if (allConst && new Set(values).size === values.length) {
313
346
  return propName;
@@ -316,11 +349,17 @@ function findSharedDiscriminator(variants: FlattenedVariant[]): string | null {
316
349
  return null;
317
350
  }
318
351
 
319
- function readConstString(schema: RawSchema | undefined | null): string | null {
352
+ /**
353
+ * Read a discriminator value pinned by `const` (or a single-value `enum`).
354
+ * Supports string and boolean literals — the latter drives the
355
+ * `active: true | false` style token response union.
356
+ */
357
+ function readConst(schema: RawSchema | undefined | null): string | boolean | null {
320
358
  if (!schema) return null;
321
- if (typeof schema.const === 'string') return schema.const;
322
- if (Array.isArray(schema.enum) && schema.enum.length === 1 && typeof schema.enum[0] === 'string') {
323
- return schema.enum[0];
359
+ if (typeof schema.const === 'string' || typeof schema.const === 'boolean') return schema.const;
360
+ if (Array.isArray(schema.enum) && schema.enum.length === 1) {
361
+ const only = schema.enum[0];
362
+ if (typeof only === 'string' || typeof only === 'boolean') return only;
324
363
  }
325
364
  return null;
326
365
  }
@@ -360,6 +399,10 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
360
399
  const modelDeps = new Set<string>();
361
400
  const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps);
362
401
  const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps);
402
+ const enumValues =
403
+ Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((e) => typeof e === 'string')
404
+ ? (schema.enum as string[])
405
+ : undefined;
363
406
  return {
364
407
  name: rawName,
365
408
  description: schema.description,
@@ -368,6 +411,7 @@ function buildField(rawName: string, schema: RawSchema, required: boolean, paren
368
411
  wireType,
369
412
  modelDeps,
370
413
  hasDateTime: schemaHasDateTime(schema),
414
+ enumValues,
371
415
  };
372
416
  }
373
417
 
@@ -487,6 +531,15 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
487
531
  depDirMap.set(rawName, irModelDir.get(stripped)!);
488
532
  }
489
533
  }
534
+ // Synthetic IR models — e.g. inline-object variant fields like the token
535
+ // response's nested `access_token` — have PascalCase names that never appear
536
+ // in rawSchemas, so the loop above misses them. Seed their directories from
537
+ // irModelDir so `collectImports` (which only receives `depDirMap` via the
538
+ // plan) can resolve a cross-service inline-object dep instead of defaulting
539
+ // to a possibly-wrong same-dir import.
540
+ for (const [irName, dir] of irModelDir) {
541
+ if (!depDirMap.has(irName)) depDirMap.set(irName, dir);
542
+ }
490
543
 
491
544
  for (const model of models) {
492
545
  const shape = detectDiscriminatedShape(model.name, rawSchemas);
@@ -503,7 +556,17 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
503
556
  for (const d of field.modelDeps) allDeps.add(d);
504
557
  }
505
558
  }
506
- const hasUnresolvableDeps = [...allDeps].some((dep) => !depDirMap.has(dep) && !irModelDir.has(dep));
559
+ // `modelDeps` may carry an inline-object synthetic name in raw form
560
+ // (`Parent_field`) while the resolution maps are keyed by the PascalCase IR
561
+ // model name (`ParentField`). Resolve either spelling before deciding a dep
562
+ // is unreachable — otherwise inline-object variant fields (e.g. the token
563
+ // response's nested `access_token`) would wrongly drop the whole plan.
564
+ const resolvable = (dep: string): boolean =>
565
+ depDirMap.has(dep) ||
566
+ irModelDir.has(dep) ||
567
+ depDirMap.has(toPascalCase(dep)) ||
568
+ irModelDir.has(toPascalCase(dep));
569
+ const hasUnresolvableDeps = [...allDeps].some((dep) => !resolvable(dep));
507
570
  if (hasUnresolvableDeps) continue;
508
571
  const modelDir = resolveDir(modelToService.get(model.name));
509
572
  plans.set(model.name, { shape, modelDir, depDirMap });
@@ -525,6 +588,7 @@ export function generateDiscriminatedFiles(
525
588
 
526
589
  function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): GeneratedFile {
527
590
  const { shape, modelDir } = plan;
591
+ if (shape.inlineUnion) return buildInlineUnionFile(plan);
528
592
  const domain = toPascalCase(shape.modelName);
529
593
  const wire = wireInterfaceName(domain);
530
594
  const lines: string[] = [];
@@ -561,6 +625,53 @@ function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gene
561
625
  };
562
626
  }
563
627
 
628
+ /**
629
+ * Emit a pure-`oneOf` discriminated union as a single inline type alias
630
+ * (`export type X = { … } | { … }`) for both the domain and wire shapes. Used
631
+ * instead of named per-variant interfaces, which read poorly for small
632
+ * (often two-variant, boolean-discriminated) unions like the token response.
633
+ */
634
+ function buildInlineUnionFile(plan: DiscriminatedPlan): GeneratedFile {
635
+ const { shape, modelDir } = plan;
636
+ const domain = toPascalCase(shape.modelName);
637
+ const wire = wireInterfaceName(domain);
638
+ const lines: string[] = [];
639
+
640
+ const imports = collectImports(plan);
641
+ if (imports.length > 0) {
642
+ for (const imp of imports) {
643
+ lines.push(`import type { ${imp.symbols.join(', ')} } from '${imp.path}';`);
644
+ }
645
+ lines.push('');
646
+ }
647
+
648
+ lines.push(...buildInlineUnionAlias(domain, shape, /*isWire*/ false));
649
+ lines.push('');
650
+ lines.push(...buildInlineUnionAlias(wire, shape, /*isWire*/ true));
651
+
652
+ return {
653
+ path: `src/${modelDir}/interfaces/${fileName(shape.modelName)}.interface.ts`,
654
+ content: lines.join('\n') + '\n',
655
+ overwriteExisting: true,
656
+ };
657
+ }
658
+
659
+ function buildInlineUnionAlias(name: string, shape: DiscriminatedShape, isWire: boolean): string[] {
660
+ const lines: string[] = [`export type ${name} =`];
661
+ shape.variants.forEach((variant, idx) => {
662
+ const isLast = idx === shape.variants.length - 1;
663
+ const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
664
+ const members = [`${discKey}: ${discLiteral(variant)}`];
665
+ for (const field of variant.fields) {
666
+ const key = isWire ? field.name : toCamelCase(field.name);
667
+ const opt = field.required ? '' : '?';
668
+ members.push(`${key}${opt}: ${inlineFieldType(field, isWire)}`);
669
+ }
670
+ lines.push(` | { ${members.join('; ')} }${isLast ? ';' : ''}`);
671
+ });
672
+ return lines;
673
+ }
674
+
564
675
  function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
565
676
  const lines: string[] = [];
566
677
  lines.push(`export interface ${name} {`);
@@ -578,7 +689,7 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
578
689
  if (shape.discriminatorDescription) {
579
690
  lines.push(` /** ${shape.discriminatorDescription} */`);
580
691
  }
581
- lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
692
+ lines.push(` ${discKey}: ${discLiteral(variant)};`);
582
693
  // Variant-specific fields
583
694
  for (const field of variant.fields) {
584
695
  pushFieldLine(lines, field, isWire);
@@ -587,6 +698,31 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
587
698
  return lines;
588
699
  }
589
700
 
701
+ /**
702
+ * The discriminator value as a TS literal: quoted for strings (`'oauth'`),
703
+ * bare for booleans (`true`).
704
+ */
705
+ function discLiteral(variant: VariantSpec): string {
706
+ return variant.discriminatorIsBoolean ? variant.discriminatorValue : `'${variant.discriminatorValue}'`;
707
+ }
708
+
709
+ /**
710
+ * Field type for an inline-union member: a literal union for inline string
711
+ * enums, otherwise the resolved domain/wire type.
712
+ *
713
+ * The `enumValues` branch deliberately ignores `isWire`: `enumValues` is only
714
+ * populated for string enums (see `buildField`), whose domain and wire
715
+ * representations are identical literal unions. Any field whose domain/wire
716
+ * types actually differ (model refs, dates, non-string enums) leaves
717
+ * `enumValues` undefined and falls through to the type-specific branch.
718
+ */
719
+ function inlineFieldType(field: FieldSpec, isWire: boolean): string {
720
+ if (field.enumValues) {
721
+ return field.enumValues.map((v) => `'${v}'`).join(' | ');
722
+ }
723
+ return isWire ? field.wireType : field.domainType;
724
+ }
725
+
590
726
  function pushFieldLine(lines: string[], field: FieldSpec, isWire: boolean): void {
591
727
  const key = isWire ? field.name : toCamelCase(field.name);
592
728
  const opt = field.required ? '' : '?';
@@ -617,7 +753,10 @@ function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
617
753
  const domain = toPascalCase(dep);
618
754
  const wire = wireInterfaceName(domain);
619
755
  const symbols = wire !== domain ? [domain, wire] : [domain];
620
- const depDir = plan.depDirMap.get(dep);
756
+ // `dep` may be a raw spec name or a synthetic inline-object name in snake
757
+ // form (`Parent_field`); `depDirMap` keys the latter under its PascalCase IR
758
+ // name, so fall back to that spelling before defaulting to a same-dir path.
759
+ const depDir = plan.depDirMap.get(dep) ?? plan.depDirMap.get(domain);
621
760
  const baseName = fileName(toSnakeFromPascal(domain));
622
761
  let importPath: string;
623
762
  if (!depDir || depDir === plan.modelDir) {
@@ -660,10 +799,16 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
660
799
  for (const variant of shape.variants)
661
800
  for (const field of variant.fields) for (const d of field.modelDeps) allDeps.add(d);
662
801
 
802
+ // Pure-`oneOf` unions are response shapes (e.g. the token response): they are
803
+ // only ever deserialized, and their inline-object fields may have no
804
+ // serializer generated. Emit a deserializer only and import just the
805
+ // deserialize helpers — mirrors the published hand-written serializer.
806
+ const deserializeOnly = shape.inlineUnion === true;
663
807
  for (const dep of [...allDeps].sort()) {
664
808
  const depDomain = toPascalCase(dep);
665
809
  const depFile = fileName(toSnakeFromPascal(depDomain));
666
- lines.push(`import { deserialize${depDomain}, serialize${depDomain} } from './${depFile}.serializer';`);
810
+ const helpers = deserializeOnly ? `deserialize${depDomain}` : `deserialize${depDomain}, serialize${depDomain}`;
811
+ lines.push(`import { ${helpers} } from './${depFile}.serializer';`);
667
812
  }
668
813
  lines.push('');
669
814
 
@@ -671,12 +816,12 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
671
816
  lines.push(`export const deserialize${domain} = (response: ${wire}): ${domain} => {`);
672
817
  lines.push(` switch (response.${shape.discriminatorProperty}) {`);
673
818
  for (const variant of shape.variants) {
674
- lines.push(` case '${variant.discriminatorValue}':`);
819
+ lines.push(` case ${discLiteral(variant)}:`);
675
820
  lines.push(` return {`);
676
821
  for (const field of shape.baseFields) {
677
822
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
678
823
  }
679
- lines.push(` ${shape.discriminatorPropertyDomain}: '${variant.discriminatorValue}',`);
824
+ lines.push(` ${shape.discriminatorPropertyDomain}: ${discLiteral(variant)},`);
680
825
  for (const field of variant.fields) {
681
826
  lines.push(` ${assignmentLine(field, /*serialize*/ false, allDeps)},`);
682
827
  }
@@ -684,29 +829,31 @@ function buildSerializerFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gen
684
829
  }
685
830
  lines.push(` default:`);
686
831
  lines.push(
687
- ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${(response as { ${shape.discriminatorProperty}: string }).${shape.discriminatorProperty}}\`);`,
832
+ ` throw new Error(\`Unknown ${shape.discriminatorProperty}: \${String((response as Record<string, unknown>).${shape.discriminatorProperty})}\`);`,
688
833
  );
689
834
  lines.push(` }`);
690
835
  lines.push(`};`);
691
- lines.push('');
692
836
 
693
- // Serializer
694
- lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
695
- lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
696
- for (const variant of shape.variants) {
697
- lines.push(` case '${variant.discriminatorValue}':`);
698
- lines.push(` return {`);
699
- for (const field of shape.baseFields) {
700
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
701
- }
702
- lines.push(` ${shape.discriminatorProperty}: '${variant.discriminatorValue}',`);
703
- for (const field of variant.fields) {
704
- lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
837
+ if (!deserializeOnly) {
838
+ lines.push('');
839
+ // Serializer
840
+ lines.push(`export const serialize${domain} = (model: ${domain}): ${wire} => {`);
841
+ lines.push(` switch (model.${shape.discriminatorPropertyDomain}) {`);
842
+ for (const variant of shape.variants) {
843
+ lines.push(` case ${discLiteral(variant)}:`);
844
+ lines.push(` return {`);
845
+ for (const field of shape.baseFields) {
846
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
847
+ }
848
+ lines.push(` ${shape.discriminatorProperty}: ${discLiteral(variant)},`);
849
+ for (const field of variant.fields) {
850
+ lines.push(` ${assignmentLine(field, /*serialize*/ true, allDeps)},`);
851
+ }
852
+ lines.push(` };`);
705
853
  }
706
- lines.push(` };`);
854
+ lines.push(` }`);
855
+ lines.push(`};`);
707
856
  }
708
- lines.push(` }`);
709
- lines.push(`};`);
710
857
 
711
858
  return {
712
859
  path: `src/${modelDir}/serializers/${fileName(shape.modelName)}.serializer.ts`,
@@ -11,6 +11,7 @@ import {
11
11
  wireInterfaceName,
12
12
  resolveMethodName,
13
13
  isAdoptedModelName,
14
+ buildServiceNameMap,
14
15
  } from './naming.js';
15
16
  import {
16
17
  collectFieldDependencies,
@@ -42,7 +43,7 @@ import {
42
43
  hasDateTimeConversion,
43
44
  } from './field-plan.js';
44
45
  import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
45
- import { isNodeOwnedService } from './options.js';
46
+ import { isNodeOwnedService, isHandOwnedType } from './options.js';
46
47
  import { unwrapListModel } from './fixtures.js';
47
48
  import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
48
49
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
@@ -244,6 +245,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
244
245
  if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
245
246
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
246
247
  if (discriminatedSkip?.has(model.name)) continue;
248
+ // Hand-owned types are declared in a hand-written file (e.g. generics the
249
+ // spec cannot express). Never generate an interface for them; references
250
+ // route to the existing declaration via its baseline source file.
251
+ if (
252
+ isHandOwnedType(ctx, model.name) ||
253
+ isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
254
+ )
255
+ continue;
247
256
  const service = modelToService.get(model.name);
248
257
  const isOwnedModel = isNodeOwnedService(ctx, service);
249
258
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
@@ -342,6 +351,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
342
351
  const crossServiceImports = new Map<string, { name: string; relPath: string }>();
343
352
  const unresolvableNames = new Set<string>();
344
353
  const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx);
354
+ // Resolve mounted/owned service names (e.g. IR `Directories` -> `DirectorySync`)
355
+ // so owned-service enum-import planning matches `generateEnums`, which keys
356
+ // ownership off the resolved name (see enums.ts).
357
+ const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
345
358
  const resolvedEnumNames = new Map<string, string>();
346
359
  for (const e of ctx.spec.enums) {
347
360
  resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
@@ -375,7 +388,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
375
388
  // even when the baseline declares the name elsewhere (usually in
376
389
  // the very file being overwritten), so import planning must
377
390
  // target the canonical path to agree with that emission.
378
- const eEnumIsOwned = isNodeOwnedService(ctx, eService);
391
+ const eEnumIsOwned = isNodeOwnedService(ctx, eService, eService ? serviceNameMap.get(eService) : undefined);
379
392
  const bSrc = eEnumIsOwned ? undefined : ((bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile);
380
393
  const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
381
394
  const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
@@ -453,7 +466,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
453
466
  const baselineEnum = ctx.apiSurface?.enums?.[dep];
454
467
  const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
455
468
  const depService = enumToService.get(dep);
456
- const depEnumIsOwned = isNodeOwnedService(ctx, depService);
469
+ const depEnumIsOwned = isNodeOwnedService(
470
+ ctx,
471
+ depService,
472
+ depService ? serviceNameMap.get(depService) : undefined,
473
+ );
457
474
  // Fall back to the live-surface declaration path: `generateEnums` skips
458
475
  // emission when the enum is already declared elsewhere in the SDK (same
459
476
  // fallback, see enums.ts), so the import must follow that declaration —
@@ -835,6 +852,12 @@ export function generateSerializers(
835
852
  if (isListMetadataModel(model) && !serializerListMetadataNeeded.has(model.name)) continue;
836
853
  if (isListWrapperModel(model) && !serializerNonPaginatedRefs.has(model.name)) continue;
837
854
  if (discriminatedSerializerSkip?.has(model.name)) continue;
855
+ // Hand-owned types keep their hand-written serializer (see generateModels).
856
+ if (
857
+ isHandOwnedType(ctx, model.name) ||
858
+ isHandOwnedType(ctx, resolveInterfaceName(model.name, ctx, { skipTypeAlias: true }))
859
+ )
860
+ continue;
838
861
  const service = modelToService.get(model.name);
839
862
  const isOwnedModel = isNodeOwnedService(ctx, service);
840
863
  if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
@@ -979,12 +1002,28 @@ export function generateSerializers(
979
1002
  }
980
1003
  const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
981
1004
  for (const [dir, stems] of serializersByDir) {
982
- if (liveRootForBarrel && !isNodeOwnedService(ctx, dir)) {
1005
+ if (liveRootForBarrel) {
1006
+ const dirIsOwned = isNodeOwnedService(ctx, dir);
983
1007
  const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
984
1008
  try {
985
1009
  for (const entry of fs.readdirSync(serializersDir)) {
986
1010
  if (!entry.endsWith('.serializer.ts')) continue;
987
- stems.add(entry.replace(/\.serializer\.ts$/, ''));
1011
+ const stem = entry.replace(/\.serializer\.ts$/, '');
1012
+ if (stems.has(stem)) continue;
1013
+ // Owned directories are otherwise fully regenerated. Only preserve a
1014
+ // hand-written serializer when it belongs to a hand-owned type (i.e.
1015
+ // exports `(de)serialize<HandOwnedType>`); stale auto-generated files
1016
+ // should be pruned and unrelated hand-written files must not collide
1017
+ // with generated exports.
1018
+ if (dirIsOwned) {
1019
+ const content = fs.readFileSync(path.join(serializersDir, entry), 'utf-8');
1020
+ if (/auto-generated by oagen/i.test(content.slice(0, 400))) continue;
1021
+ const serializerFns = [
1022
+ ...content.matchAll(/export\s+(?:const|function)\s+((?:de)?serialize[A-Za-z0-9_]+)/g),
1023
+ ].map((m) => m[1].replace(/^(?:de)?serialize/, ''));
1024
+ if (!serializerFns.some((typeName) => isHandOwnedType(ctx, typeName))) continue;
1025
+ }
1026
+ stems.add(stem);
988
1027
  }
989
1028
  } catch {
990
1029
  // Directory doesn't exist yet — only this-pass serializers will appear.
@@ -38,6 +38,17 @@ export interface NodeEmitterOptions {
38
38
  * without affecting other languages.
39
39
  */
40
40
  operationOverrides?: Record<string, OperationOverride>;
41
+ /**
42
+ * Type/model names whose hand-written declarations are authoritative even
43
+ * inside an owned service. The emitter will NOT generate an interface or
44
+ * serializer for these names; instead it treats them like a baseline type,
45
+ * routing imports and barrel exports to the existing hand-written file.
46
+ *
47
+ * Use this to keep hand-owned generics the OpenAPI spec cannot express
48
+ * (for example `DirectoryUserWithGroups<TCustomAttributes>`) while still
49
+ * letting oagen own the rest of the service.
50
+ */
51
+ handOwnedTypes?: string[];
41
52
  }
42
53
 
43
54
  export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
@@ -67,3 +78,15 @@ export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string |
67
78
  name !== undefined ? ownedLookupNames(name).some((candidate) => owned.has(normalizeServiceName(candidate))) : false,
68
79
  );
69
80
  }
81
+
82
+ /**
83
+ * True when `name` is a hand-owned type (see {@link NodeEmitterOptions.handOwnedTypes}).
84
+ * Hand-owned types are never generated; the emitter defers to the existing
85
+ * hand-written declaration and routes imports/barrel exports to it.
86
+ */
87
+ export function isHandOwnedType(ctx: EmitterContext, name: string | undefined): boolean {
88
+ if (name === undefined) return false;
89
+ const configured = nodeOptions(ctx).handOwnedTypes;
90
+ if (!configured || configured.length === 0) return false;
91
+ return configured.includes(name);
92
+ }