@workos/oagen-emitters 0.16.0 → 0.17.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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-DuB1UozS.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-BLnR-FMi.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -40,12 +40,12 @@
40
40
  "devDependencies": {
41
41
  "@commitlint/cli": "^21.0.2",
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
- "@types/node": "^25.9.1",
43
+ "@types/node": "^25.9.3",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.53.0",
46
- "oxlint": "^1.68.0",
47
- "prettier": "^3.8.3",
48
- "tsdown": "^0.22.1",
45
+ "oxfmt": "^0.54.0",
46
+ "oxlint": "^1.69.0",
47
+ "prettier": "^3.8.4",
48
+ "tsdown": "^0.22.2",
49
49
  "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
51
  "vitest": "^4.1.8"
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.0"
57
+ "@workos/oagen": "^0.22.5"
58
58
  }
59
59
  }
package/src/go/index.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
 
12
12
  import { generateModels } from './models.js';
13
13
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
14
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
14
15
  import { generateEnums } from './enums.js';
15
16
  import { generateResources } from './resources.js';
16
17
  import { generateClient } from './client.js';
@@ -47,7 +48,11 @@ export const goEmitter: Emitter = {
47
48
  }
48
49
  return m;
49
50
  });
50
- return ensureTrailingNewlines(generateModels(goModels, ctx));
51
+ // Go has no sum types: a discriminated-union field (e.g. ApiKey.owner)
52
+ // renders as its first variant, dropping fields that only exist on later
53
+ // variants (organization_id on the user owner). Flatten such unions into a
54
+ // single superset struct so every variant field survives.
55
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(goModels), ctx));
51
56
  },
52
57
 
53
58
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
21
22
 
22
23
  /** Ensure every generated file ends with a trailing newline. */
23
24
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -49,7 +50,11 @@ export const kotlinEmitter: Emitter = {
49
50
  }
50
51
  return m;
51
52
  });
52
- return ensureTrailingNewlines(generateModels(kotlinModels, ctx));
53
+ // Kotlin renders a discriminated-union field as its first variant's data
54
+ // class, so fields unique to later variants (organization_id on the user
55
+ // owner) are lost. Flatten such unions into one superset data class so
56
+ // every variant field is reachable.
57
+ return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(kotlinModels), ctx));
53
58
  },
54
59
 
55
60
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -78,8 +83,9 @@ export const kotlinEmitter: Emitter = {
78
83
 
79
84
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
80
85
  // Pass enriched models so round-trip tests see the full field set
81
- // (including optional oneOf-enriched fields) and can filter accurately.
82
- const enrichedModels = enrichModelsFromSpec(spec.models);
86
+ // (including optional oneOf-enriched fields and flattened discriminated-
87
+ // union owner fields) and can filter accurately.
88
+ const enrichedModels = flattenDiscriminatedUnionFields(enrichModelsFromSpec(spec.models));
83
89
  const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
84
90
  return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
85
91
  },
package/src/node/enums.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile, Model, Service } from '@workos/oagen';
2
- import { assignModelsToServices, collectFieldDependencies, toPascalCase, walkTypeRef } from '@workos/oagen';
2
+ import { collectFieldDependencies, toPascalCase, walkTypeRef } from '@workos/oagen';
3
3
  import { fileName, resolveServiceDir, buildServiceNameMap } from './naming.js';
4
- import { docComment } from './utils.js';
4
+ import { docComment, assignModelsToEmittableServices } from './utils.js';
5
5
  import { isInlineEnum } from './type-map.js';
6
+ import { isNodeOwnedService } from './options.js';
6
7
  import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
7
8
 
8
9
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -30,7 +31,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
30
31
  if (dirName === 'common' && !baselineSourceFile && (ctx.outputDir || ctx.targetDir || ctx.apiSurface)) {
31
32
  continue;
32
33
  }
33
- if (baselineSourceFile && baselineSourceFile !== generatedPath) {
34
+ // The declared-elsewhere skip must not fire for OWNED services: the
35
+ // "elsewhere" is typically the very interface file the owned-service
36
+ // regeneration is simultaneously overwriting (e.g. legacy enums declared
37
+ // inline in organization-domain.interface.ts), so skipping here leaves
38
+ // the names referenced by generated code but declared nowhere. Under
39
+ // ownership the canonical per-service module is the source of truth —
40
+ // emit it; the model emitter plans its import from the same path.
41
+ const isOwnedEnumService = isNodeOwnedService(ctx, service, service ? serviceNameMap.get(service) : undefined);
42
+ if (baselineSourceFile && baselineSourceFile !== generatedPath && !isOwnedEnumService) {
34
43
  continue;
35
44
  }
36
45
 
@@ -160,7 +169,11 @@ export function assignEnumsToServices(
160
169
  }
161
170
 
162
171
  if (models.length > 0) {
163
- const modelToService = assignModelsToServices(models, services, ctx?.modelHints);
172
+ // Use the emittable-services assignment (not the raw engine one) so an
173
+ // enum referenced through a model that was re-homed into an owned
174
+ // service directory follows the model — `generateEnums` emission and the
175
+ // model's enum imports must agree on the directory.
176
+ const modelToService = assignModelsToEmittableServices(models, services, ctx);
164
177
  for (const model of models) {
165
178
  const service = modelToService.get(model.name);
166
179
  if (!service) continue;
package/src/node/index.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  Enum,
9
9
  Service,
10
10
  } from '@workos/oagen';
11
+ import { toPascalCase } from '@workos/oagen';
11
12
  import * as fs from 'node:fs';
12
13
  import * as path from 'node:path';
13
14
 
@@ -17,11 +18,19 @@ import { generateResources, resolveResourceClassName, resolveResourceDir } from
17
18
  import { generateClient } from './client.js';
18
19
  import { generateTests as generateTestFiles } from './tests.js';
19
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+ import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
20
22
  import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
21
- import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
23
+ import {
24
+ buildLiveSurface,
25
+ emptyLiveSurface,
26
+ mergeGeneratedClassMethodsIntoExisting,
27
+ setActiveLiveSurface,
28
+ type LiveSurface,
29
+ } from './live-surface.js';
22
30
  import {
23
31
  setBaselineSerializedNames,
24
32
  setBaselineInterfaceNames,
33
+ setBaselineDeclaredNames,
25
34
  setAdoptedModelNames,
26
35
  setDiscriminatedModelNames,
27
36
  setStructurallyRenamedDomainNames,
@@ -31,7 +40,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
31
40
  import { isNodeOwnedService, nodeOptions } from './options.js';
32
41
  import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
33
42
  import { groupByMount } from '../shared/resolved-ops.js';
34
- import { assignModelsToServices, createServiceDirResolver } from './utils.js';
43
+ import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
35
44
  import { fileName } from './naming.js';
36
45
 
37
46
  /**
@@ -58,6 +67,24 @@ function getEmittedPaths(ctx: EmitterContext): Set<string> {
58
67
  return set;
59
68
  }
60
69
 
70
+ /**
71
+ * Every `GeneratedFile` the node emitter has produced so far in this ctx,
72
+ * keyed by path. All emitter hooks share one ctx (and the engine only reads
73
+ * file contents after the last hook returns), so the final hook can run a
74
+ * whole-run pass over files emitted by earlier hooks — see
75
+ * `enforceEmittedImportInvariant`.
76
+ */
77
+ const emittedFilesCache = new WeakMap<EmitterContext, Map<string, GeneratedFile>>();
78
+
79
+ function getEmittedFiles(ctx: EmitterContext): Map<string, GeneratedFile> {
80
+ let map = emittedFilesCache.get(ctx);
81
+ if (!map) {
82
+ map = new Map();
83
+ emittedFilesCache.set(ctx, map);
84
+ }
85
+ return map;
86
+ }
87
+
61
88
  function getSurface(ctx: EmitterContext): LiveSurface {
62
89
  let surface = surfaceCache.get(ctx);
63
90
  if (surface) return surface;
@@ -102,6 +129,18 @@ function getSurface(ctx: EmitterContext): LiveSurface {
102
129
  for (const name of surface.interfaces.keys()) allInterfaces.add(name);
103
130
  setBaselineInterfaceNames(allInterfaces);
104
131
 
132
+ // Every DECLARED baseline name — interfaces and type aliases, from both
133
+ // the api-surface JSON and the disk walk (whose `interfaces` map already
134
+ // includes `export type` aliases). `resolveInterfaceName` uses this to
135
+ // let exact-name declarations preempt structural renames: an alias-form
136
+ // file (`export type X = Y;`) carries no fields, so the engine's
137
+ // structural matcher would otherwise re-point IR model X at a similarly
138
+ // shaped interface and emit renamed duplicates that flip the file's form
139
+ // on every regeneration.
140
+ const declaredNames = new Set<string>(allInterfaces);
141
+ for (const name of Object.keys(ctx.apiSurface?.typeAliases ?? {})) declaredNames.add(name);
142
+ setBaselineDeclaredNames(declaredNames);
143
+
105
144
  // Inline-enum optimization is intentionally disabled. workos-node emits the
106
145
  // dual `const X = {...} as const; type X = ...` pattern so callers can use
107
146
  // members at runtime (e.g. `GenerateLinkIntent.SSO`). Inlining the type
@@ -342,6 +381,16 @@ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
342
381
  return dir !== undefined && policy.ownedServiceDirs.has(dir);
343
382
  }
344
383
 
384
+ /** Read the current on-disk content of a live-surface file, if present. */
385
+ function readExistingSurfaceFile(surface: LiveSurface, relPath: string): string | null {
386
+ if (!surface.rootDir) return null;
387
+ try {
388
+ return fs.readFileSync(path.join(surface.rootDir, relPath), 'utf8');
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
345
394
  function extractRelativeImportPaths(content: string, fromPath: string): string[] {
346
395
  const dir = path.dirname(fromPath);
347
396
  const paths: string[] = [];
@@ -438,7 +487,26 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
438
487
  // `@oagen-ignore-start`/`@oagen-ignore-end` regions inside the file
439
488
  // are still preserved by `overwriteWithPreservedRegions` in the
440
489
  // engine.
490
+ //
491
+ // Exception: a NOT-owned, NOT-adopted service can receive a PARTIAL
492
+ // resource emission — resources.ts filters out operations already
493
+ // covered by the baseline class, leaving only the new methods (see
494
+ // generateResourceClass). Forcing a full overwrite with that partial
495
+ // class deletes the existing public methods (workos-node's
496
+ // api-keys.ts lost four methods when the spec gained one operation).
497
+ // When the generated class would drop methods that the on-disk class
498
+ // declares, merge instead: keep the existing file text verbatim and
499
+ // append only the new methods plus the imports they need.
441
500
  if (surface.autogenFiles.has(f.path) || ownedPath) {
501
+ const dir = topLevelDir(f.path);
502
+ const isAdoptedDirPath = dir !== undefined && policy.adoptedServiceDirs.has(dir);
503
+ if (!ownedPath && !isAdoptedDirPath && surface.autogenFiles.has(f.path)) {
504
+ const existingText = readExistingSurfaceFile(surface, f.path);
505
+ if (existingText !== null) {
506
+ const merged = mergeGeneratedClassMethodsIntoExisting(existingText, f.content);
507
+ if (merged !== null) f.content = merged;
508
+ }
509
+ }
442
510
  f.overwriteExisting = true;
443
511
  f.skipIfExists = false;
444
512
  }
@@ -449,10 +517,192 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
449
517
  out.push(f);
450
518
  }
451
519
  const emitted = getEmittedPaths(ctx);
452
- for (const f of out) emitted.add(f.path);
520
+ const emittedFiles = getEmittedFiles(ctx);
521
+ for (const f of out) {
522
+ emitted.add(f.path);
523
+ emittedFiles.set(f.path, f);
524
+ }
453
525
  return out;
454
526
  }
455
527
 
528
+ // ---------------------------------------------------------------------------
529
+ // Import-resolution invariant
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /**
533
+ * Matches single-line `import`/`export … from './relative'` statements — the
534
+ * only form the node emitter produces. Captures: keyword, optional ` type`
535
+ * modifier, the binding clause (`* [as ns]` or `{ … }`), and the module path.
536
+ */
537
+ const RELATIVE_FROM_STMT_RE =
538
+ /^(import|export)(\s+type)?\s+(\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"];?\s*$/;
539
+
540
+ const EXPORTED_DECL_RE =
541
+ /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:interface|class|enum|function|const|let|var|type)\s+([A-Za-z_$][\w$]*)/gm;
542
+ const EXPORTED_CLAUSE_RE = /^export\s+(?:type\s+)?\{([^}]*)\}/gm;
543
+
544
+ /** Repo-relative paths a relative import specifier may resolve to. */
545
+ function importTargetCandidates(fromPath: string, spec: string): string[] {
546
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromPath), spec));
547
+ return [base, `${base}.ts`, `${base}/index.ts`];
548
+ }
549
+
550
+ /** Index exported symbol → file path across this run's emitted contents. */
551
+ function indexEmittedExports(files: GeneratedFile[]): Map<string, string> {
552
+ const index = new Map<string, string>();
553
+ for (const f of files) {
554
+ if (!f.path.endsWith('.ts') || !f.content) continue;
555
+ for (const m of f.content.matchAll(EXPORTED_DECL_RE)) {
556
+ if (!index.has(m[1])) index.set(m[1], f.path);
557
+ }
558
+ for (const m of f.content.matchAll(EXPORTED_CLAUSE_RE)) {
559
+ for (const raw of m[1].split(',')) {
560
+ const entry = raw.trim();
561
+ if (!entry) continue;
562
+ const parts = entry.split(/\s+as\s+/);
563
+ const exported = (parts[1] ?? parts[0]).replace(/^type\s+/, '').trim();
564
+ if (exported && !index.has(exported)) index.set(exported, f.path);
565
+ }
566
+ }
567
+ }
568
+ return index;
569
+ }
570
+
571
+ /**
572
+ * Enforce: every relative import/re-export path in emitted code resolves to
573
+ * either (i) a file emitted in the same run, or (ii) a file that already
574
+ * exists on disk in the target SDK.
575
+ *
576
+ * Violations observed in real generations (all TS2307 in otherwise-valid
577
+ * output): serializer imports pointing at canonical paths while the function
578
+ * lives in a legacy hand serializer under a different filename; barrels
579
+ * re-exporting a module-local enum file whose declaration lives under
580
+ * `src/common/interfaces`; barrels exporting interface files that no run
581
+ * emits at all.
582
+ *
583
+ * Repair strategy, per statement whose target is neither emitted nor on
584
+ * disk:
585
+ * 1. Locate each imported symbol (this run's emissions first, then the
586
+ * live-surface declaration maps) and rewrite the path to where the
587
+ * symbol actually lives — splitting into one statement per location
588
+ * when symbols are spread across files.
589
+ * 2. `export * from` / namespace imports carry no symbol list, so derive
590
+ * the expected symbol from the file stem (`foo-bar.interface` →
591
+ * `FooBar`, `foo.serializer` → `deserializeFoo`/`serializeFoo`).
592
+ * 3. When a symbol exists nowhere, drop the statement and warn — a missing
593
+ * named export fails loudly at the usage site instead of as a phantom
594
+ * module, and a barrel line for a never-emitted file is pure noise.
595
+ *
596
+ * Mutates `f.content` in place and returns the warnings issued.
597
+ */
598
+ export function enforceEmittedImportInvariant(
599
+ files: Iterable<GeneratedFile>,
600
+ emittedPaths: Set<string>,
601
+ surface: LiveSurface,
602
+ ): string[] {
603
+ const fileList = [...files];
604
+ const emittedSymbols = indexEmittedExports(fileList);
605
+ const warnings: string[] = [];
606
+
607
+ const targetExists = (fromPath: string, spec: string): boolean =>
608
+ importTargetCandidates(fromPath, spec).some((p) => emittedPaths.has(p) || surface.files.has(p));
609
+
610
+ const locateSymbol = (name: string): string | undefined =>
611
+ emittedSymbols.get(name) ??
612
+ surface.functions.get(name) ??
613
+ surface.interfaces.get(name)?.filePath ??
614
+ surface.classes.get(name)?.filePath;
615
+
616
+ for (const f of fileList) {
617
+ if (!f.path.endsWith('.ts') || !f.content) continue;
618
+ let changed = false;
619
+ const outLines: string[] = [];
620
+ for (const line of f.content.split('\n')) {
621
+ const m = line.match(RELATIVE_FROM_STMT_RE);
622
+ if (!m || targetExists(f.path, m[4])) {
623
+ outLines.push(line);
624
+ continue;
625
+ }
626
+ const [, keyword, typeMod, clause, spec] = m;
627
+ const repaired = repairUnresolvableStatement(f.path, keyword, typeMod ?? '', clause, spec, locateSymbol);
628
+ changed = true;
629
+ outLines.push(...repaired.lines);
630
+ if (repaired.warning) warnings.push(repaired.warning);
631
+ }
632
+ if (changed) f.content = outLines.join('\n');
633
+ }
634
+
635
+ for (const w of warnings) console.warn(w);
636
+ return warnings;
637
+ }
638
+
639
+ function repairUnresolvableStatement(
640
+ fromPath: string,
641
+ keyword: string,
642
+ typeMod: string,
643
+ clause: string,
644
+ spec: string,
645
+ locateSymbol: (name: string) => string | undefined,
646
+ ): { lines: string[]; warning?: string } {
647
+ if (clause.startsWith('{')) {
648
+ const entries = clause
649
+ .slice(1, -1)
650
+ .split(',')
651
+ .map((e) => e.trim())
652
+ .filter(Boolean);
653
+ const byLocation = new Map<string, string[]>();
654
+ const missing: string[] = [];
655
+ for (const entry of entries) {
656
+ const source = entry
657
+ .split(/\s+as\s+/)[0]
658
+ .replace(/^type\s+/, '')
659
+ .trim();
660
+ const location = locateSymbol(source);
661
+ if (!location) {
662
+ missing.push(source);
663
+ continue;
664
+ }
665
+ if (location === fromPath) continue; // declared locally — no import needed
666
+ const group = byLocation.get(location);
667
+ if (group) {
668
+ group.push(entry);
669
+ } else {
670
+ byLocation.set(location, [entry]);
671
+ }
672
+ }
673
+ // Emit the symbols we *can* relocate; only the genuinely-missing ones are
674
+ // dropped (and warned about). Returning [] for the whole clause when any
675
+ // one symbol is missing would also discard the resolvable symbols, failing
676
+ // them with TS2305 at their usage sites — the breakage this pass prevents.
677
+ const lines = [...byLocation].map(
678
+ ([location, group]) =>
679
+ `${keyword}${typeMod} { ${group.join(', ')} } from '${relativeImport(fromPath, location)}';`,
680
+ );
681
+ const warning =
682
+ missing.length > 0
683
+ ? `oagen(node): dropped unresolvable symbol(s) from ${keyword} in ${fromPath}: '${spec}' — found neither in this run's output nor in the target SDK: ${missing.join(', ')}`
684
+ : undefined;
685
+ return { lines, warning };
686
+ }
687
+
688
+ // `* [as ns]` — no symbol list; derive the expected symbol from the stem.
689
+ const stem = spec.split('/').pop() ?? '';
690
+ let location: string | undefined;
691
+ if (stem.endsWith('.interface')) {
692
+ location = locateSymbol(toPascalCase(stem.slice(0, -'.interface'.length)));
693
+ } else if (stem.endsWith('.serializer')) {
694
+ const base = toPascalCase(stem.slice(0, -'.serializer'.length));
695
+ location = locateSymbol(`deserialize${base}`) ?? locateSymbol(`serialize${base}`);
696
+ }
697
+ if (location && location !== fromPath) {
698
+ return { lines: [`${keyword}${typeMod} ${clause} from '${relativeImport(fromPath, location)}';`] };
699
+ }
700
+ return {
701
+ lines: [],
702
+ warning: `oagen(node): dropped unresolvable ${keyword} in ${fromPath}: '${spec}' — module is neither emitted this run nor present in the target SDK`,
703
+ };
704
+ }
705
+
456
706
  /**
457
707
  * Re-declare prior-manifest paths that we did not emit this run so manifest
458
708
  * pruning can tell "intentionally removed" from "untouched but still managed."
@@ -517,7 +767,7 @@ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): Ge
517
767
  function enrichModelsForNode(models: Model[]): Model[] {
518
768
  const enriched = enrichModelsFromSpec(models);
519
769
  const originalByName = new Map(models.map((m) => [m.name, m]));
520
- return enriched.map((m) => {
770
+ const restored = enriched.map((m) => {
521
771
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
522
772
  const original = originalByName.get(m.name);
523
773
  if (original && original.fields.length > 0) {
@@ -526,6 +776,11 @@ function enrichModelsForNode(models: Model[]): Model[] {
526
776
  }
527
777
  return m;
528
778
  });
779
+ // Field-level discriminated unions (e.g. ApiKey.owner) otherwise render as
780
+ // `FirstVariant | SecondVariant`; collapse them to one flat superset
781
+ // interface so callers see every variant field (organization_id on the user
782
+ // owner) on a single type — parity with the other flat-emit languages.
783
+ return flattenDiscriminatedUnionFields(restored);
529
784
  }
530
785
 
531
786
  export const nodeEmitter: Emitter = {
@@ -593,7 +848,18 @@ export const nodeEmitter: Emitter = {
593
848
  const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
594
849
  ? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
595
850
  : [];
596
- return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
851
+ const result = [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
852
+
853
+ // Final whole-run pass: this is the last generate hook, every hook shares
854
+ // `nodeCtx`, and the engine reads contents only after all hooks return —
855
+ // so the emitted-files cache now covers the complete run and repairs here
856
+ // reach files produced by earlier hooks. Greenfield runs are exempt: with
857
+ // no SDK on disk, "resolves to an existing file" has no meaning and the
858
+ // SDK core (workos.ts, common/) is intentionally not emitted.
859
+ if (managedPathsFor(nodeCtx, surface).size > 0) {
860
+ enforceEmittedImportInvariant(getEmittedFiles(nodeCtx).values(), getEmittedPaths(nodeCtx), surface);
861
+ }
862
+ return result;
597
863
  },
598
864
 
599
865
  // No operations map needed — the manifest belongs to the staging+target flow,