@workos/oagen-emitters 0.14.3 → 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 (44) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-D0qLBiGv.mjs → plugin-CO4RFgAW.mjs} +983 -270
  6. package/dist/plugin-CO4RFgAW.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +9 -9
  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 +20 -28
  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/ruby/rbi.ts +12 -6
  36. package/src/rust/resources.ts +10 -7
  37. package/src/shared/non-spec-services.ts +0 -5
  38. package/test/go/enums.test.ts +24 -0
  39. package/test/node/resources.test.ts +11 -1
  40. package/test/node/tests.test.ts +3 -3
  41. package/test/php/client.test.ts +0 -1
  42. package/test/php/resources.test.ts +50 -0
  43. package/test/rust/resources.test.ts +9 -0
  44. package/dist/plugin-D0qLBiGv.mjs.map +0 -1
@@ -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
+ }
package/src/node/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  setBaselineSerializedNames,
24
24
  setBaselineInterfaceNames,
25
25
  setAdoptedModelNames,
26
+ setDiscriminatedModelNames,
26
27
  setStructurallyRenamedDomainNames,
27
28
  resolveInterfaceName,
28
29
  } from './naming.js';
@@ -341,14 +342,66 @@ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
341
342
  return dir !== undefined && policy.ownedServiceDirs.has(dir);
342
343
  }
343
344
 
345
+ function extractRelativeImportPaths(content: string, fromPath: string): string[] {
346
+ const dir = path.dirname(fromPath);
347
+ const paths: string[] = [];
348
+ const re = /from\s+['"](\.[^'"]+)['"]/g;
349
+ let match: RegExpExecArray | null;
350
+ while ((match = re.exec(content)) !== null) {
351
+ paths.push(path.normalize(path.join(dir, match[1])) + '.ts');
352
+ }
353
+ return paths;
354
+ }
355
+
344
356
  function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
345
357
  const out: GeneratedFile[] = [];
346
358
  const policy = buildLiveSurfacePolicy(ctx, surface);
359
+ const filesByPath = new Map(files.map((f) => [f.path, f]));
360
+ const dependencyAllowedPaths = new Set<string>();
361
+ const queue: string[] = [];
362
+
363
+ for (const f of files) {
364
+ if (f.integrateTarget === false) continue;
365
+ if (!canCreateNewPath(f.path, policy)) continue;
366
+ for (const importPath of extractRelativeImportPaths(f.content, f.path)) {
367
+ if (
368
+ filesByPath.has(importPath) &&
369
+ !canCreateNewPath(importPath, policy) &&
370
+ !dependencyAllowedPaths.has(importPath)
371
+ ) {
372
+ dependencyAllowedPaths.add(importPath);
373
+ queue.push(importPath);
374
+ }
375
+ }
376
+ }
377
+
378
+ while (queue.length > 0) {
379
+ const relPath = queue.pop()!;
380
+ const file = filesByPath.get(relPath);
381
+ if (!file) continue;
382
+ for (const importPath of extractRelativeImportPaths(file.content, relPath)) {
383
+ if (
384
+ filesByPath.has(importPath) &&
385
+ !canCreateNewPath(importPath, policy) &&
386
+ !dependencyAllowedPaths.has(importPath)
387
+ ) {
388
+ dependencyAllowedPaths.add(importPath);
389
+ queue.push(importPath);
390
+ }
391
+ }
392
+ }
393
+
347
394
  for (const f of files) {
348
395
  const ownedPath = isOwnedPath(f.path, policy);
349
396
  if (f.integrateTarget === false) continue;
350
397
  if (surface.protectedFiles.has(f.path)) continue;
351
- if (policy.hasExistingSdk && !policy.managedPaths.has(f.path) && !canCreateNewPath(f.path, policy)) continue;
398
+ if (
399
+ policy.hasExistingSdk &&
400
+ !policy.managedPaths.has(f.path) &&
401
+ !canCreateNewPath(f.path, policy) &&
402
+ !dependencyAllowedPaths.has(f.path)
403
+ )
404
+ continue;
352
405
 
353
406
  // Hand-written files (on disk, no `auto-generated by oagen` header) →
354
407
  // drop. The engine would otherwise prepend the header on
@@ -372,7 +425,8 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
372
425
  const dir = topLevelDir(f.path);
373
426
  const isAdoptedDir = dir !== undefined && policy.adoptedServiceDirs.has(dir);
374
427
  const isManagedDir = ownedPath || isAdoptedDir;
375
- if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path)) continue;
428
+ if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path) && !(ownedPath && policy.regenerateOwnedTests))
429
+ continue;
376
430
  if (!isManagedDir && !surface.autogenFiles.has(f.path)) continue;
377
431
  if (isManagedDir && !policy.regenerateOwnedTests) continue;
378
432
  }
@@ -487,7 +541,9 @@ export const nodeEmitter: Emitter = {
487
541
  // name set on ctx so models.ts skips emitting an interface/serializer —
488
542
  // the discriminated module owns those paths instead.
489
543
  const discPlans = planDiscriminatedModels(enriched, nodeCtx);
490
- (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = new Set(discPlans.keys());
544
+ const discriminatedNames = new Set(discPlans.keys());
545
+ (nodeCtx as { _discriminatedModelNames?: Set<string> })._discriminatedModelNames = discriminatedNames;
546
+ setDiscriminatedModelNames(discriminatedNames);
491
547
  const standardFiles = generateModelsAndSerializers(enriched, nodeCtx);
492
548
  const discFiles = generateDiscriminatedFiles(discPlans, nodeCtx);
493
549
  return applyLiveSurface([...standardFiles, ...discFiles], nodeCtx, surface);
@@ -223,6 +223,20 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
223
223
  // wrapper's interface importing from a file that was never written.
224
224
  const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
225
225
 
226
+ for (const originalModel of models) {
227
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
228
+ if (!reachableModels.has(model.name)) continue;
229
+ if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
230
+ const service = modelToService.get(model.name);
231
+ const isOwnedModel = isNodeOwnedService(ctx, service);
232
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
233
+ const canonicalName = dedup.get(model.name);
234
+ if (canonicalName) {
235
+ forceGenerate.add(canonicalName);
236
+ if (interfaceEligibleModels) interfaceEligibleModels.add(canonicalName);
237
+ }
238
+ }
239
+
226
240
  for (const originalModel of models) {
227
241
  const model = projectedByName.get(originalModel.name) ?? originalModel;
228
242
  if (!reachableModels.has(model.name)) continue;
@@ -392,6 +406,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
392
406
  const depName = resolveInterfaceName(dep, ctx);
393
407
  const depService = modelToService.get(dep);
394
408
  const depDir = resolveDir(depService);
409
+ const depIsOwned = isNodeOwnedService(ctx, depService);
395
410
 
396
411
  // When the resolver maps the IR name to a different baseline interface
397
412
  // (via `overlayLookup.modelNameByIR` structural match), the import
@@ -400,7 +415,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
400
415
  // emitter never generates — the canonical baseline file is at a
401
416
  // different stem (e.g. `create-audit-log-event-options.interface`).
402
417
  const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
403
- const baselineSrc = (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
418
+ const baselineSrc = depIsOwned
419
+ ? undefined
420
+ : (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
404
421
 
405
422
  // Self-reference: the dependency lives in the file we're currently
406
423
  // emitting. Skip the import — it's already in scope.
@@ -697,6 +714,13 @@ export function generateSerializers(
697
714
  const responseReachableModels = resourceUsage
698
715
  ? expandModelRoots(resourceUsage.responseRoots, projectedByName)
699
716
  : undefined;
717
+ // Models reachable from any request — only these need a `serialize<X>`.
718
+ // A model used solely as a response body can safely be deserialize-only;
719
+ // emitting its serialize half is both unused and brittle when it contains
720
+ // legacy nested response models that intentionally have no serialize helper.
721
+ const requestReachableModels = resourceUsage
722
+ ? expandModelRoots(resourceUsage.requestRoots, projectedByName)
723
+ : undefined;
700
724
 
701
725
  const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
702
726
 
@@ -759,6 +783,20 @@ export function generateSerializers(
759
783
  // Mirror the interface-emission gate (see `generateModels`).
760
784
  const serializerListMetadataNeeded = collectReferencedListMetadataModels(models, serializerNonPaginatedRefs);
761
785
 
786
+ for (const originalModel of models) {
787
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
788
+ if (!serializerReachable.has(model.name)) continue;
789
+ if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
790
+ const service = modelToService.get(model.name);
791
+ const isOwnedModel = isNodeOwnedService(ctx, service);
792
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
793
+ const canonicalName = dedup.get(model.name);
794
+ if (canonicalName) {
795
+ forceGenerateSerializer.add(canonicalName);
796
+ if (serializerEligibleModels) serializerEligibleModels.add(canonicalName);
797
+ }
798
+ }
799
+
762
800
  const eligibleModels: Model[] = [];
763
801
  for (const originalModel of models) {
764
802
  const model = projectedByName.get(originalModel.name) ?? originalModel;
@@ -781,14 +819,9 @@ export function generateSerializers(
781
819
  const responseName = wireInterfaceName(domainName);
782
820
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
783
821
  const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
784
- const shouldSkip = shouldSkipSerializeForModel(
785
- model,
786
- baselineResponse,
787
- baselineDomain,
788
- dedup,
789
- skippedSerializeModels,
790
- ctx,
791
- );
822
+ const shouldSkip =
823
+ (requestReachableModels !== undefined && !requestReachableModels.has(model.name)) ||
824
+ shouldSkipSerializeForModel(model, baselineResponse, baselineDomain, dedup, skippedSerializeModels, ctx);
792
825
  if (shouldSkip) {
793
826
  skippedSerializeModels.add(model.name);
794
827
  }
@@ -812,27 +845,37 @@ export function generateSerializers(
812
845
 
813
846
  if (serializerPath === canonSerializerPath) continue;
814
847
  if (domainName === canonDomainName) continue;
815
- const rel = relativeImport(serializerPath, canonSerializerPath);
816
- const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
817
- const canonSkipDeserialize =
818
- responseReachableModels !== undefined &&
819
- !responseReachableModels.has(canonicalName) &&
820
- !responseReachableModels.has(model.name);
821
- if (canonSkipSerialize && canonSkipDeserialize) continue;
822
- const parts: string[] = [];
823
- if (!canonSkipDeserialize) {
824
- parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
825
- }
826
- if (!canonSkipSerialize) {
827
- parts.push(`serialize${canonDomainName} as serialize${domainName}`);
848
+
849
+ const aliasNeedsDeserialize = responseReachableModels === undefined || responseReachableModels.has(model.name);
850
+ const canonicalHasDeserialize =
851
+ responseReachableModels === undefined || responseReachableModels.has(canonicalName);
852
+ const canAliasToCanonical = !aliasNeedsDeserialize || canonicalHasDeserialize;
853
+ if (canAliasToCanonical) {
854
+ const rel = relativeImport(serializerPath, canonSerializerPath);
855
+ const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
856
+ const canonSkipDeserialize =
857
+ responseReachableModels !== undefined &&
858
+ !responseReachableModels.has(canonicalName) &&
859
+ !responseReachableModels.has(model.name);
860
+ if (canonSkipSerialize && canonSkipDeserialize) continue;
861
+ const parts: string[] = [];
862
+ if (!canonSkipDeserialize) {
863
+ parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
864
+ }
865
+ if (!canonSkipSerialize) {
866
+ parts.push(`serialize${canonDomainName} as serialize${domainName}`);
867
+ }
868
+ const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
869
+ files.push({
870
+ path: serializerPath,
871
+ content: reexportContent,
872
+ overwriteExisting: true,
873
+ });
874
+ continue;
828
875
  }
829
- const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
830
- files.push({
831
- path: serializerPath,
832
- content: reexportContent,
833
- overwriteExisting: true,
834
- });
835
- continue;
876
+ // The alias is response-reachable, but the canonical model is
877
+ // request-only. Generate a local serializer instead of re-exporting a
878
+ // deserialize helper that the canonical serializer intentionally omits.
836
879
  }
837
880
 
838
881
  const dirName = resolveDir(service);
@@ -906,7 +949,7 @@ export function generateSerializers(
906
949
  }
907
950
  const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
908
951
  for (const [dir, stems] of serializersByDir) {
909
- if (liveRootForBarrel) {
952
+ if (liveRootForBarrel && !isNodeOwnedService(ctx, dir)) {
910
953
  const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
911
954
  try {
912
955
  for (const entry of fs.readdirSync(serializersDir)) {
@@ -73,6 +73,16 @@ export function isAdoptedModelName(name: string): boolean {
73
73
  return adoptedModelNames.has(name);
74
74
  }
75
75
 
76
+ /**
77
+ * IR model names handled by the discriminated-models module. These must not
78
+ * be remapped by the structural matcher because that module emits files and
79
+ * helpers under the original IR names.
80
+ */
81
+ let discriminatedModelNames: Set<string> = new Set();
82
+ export function setDiscriminatedModelNames(names: Set<string>): void {
83
+ discriminatedModelNames = names;
84
+ }
85
+
76
86
  /**
77
87
  * Domain names that `resolveInterfaceName` reached via a structural rename
78
88
  * — the resolved name differs from the IR model's own name. `wireInterfaceName`
@@ -226,7 +236,10 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
226
236
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
227
237
  if (existing) return existing;
228
238
 
229
- let inferred = adoptedModelNames.has(name) ? undefined : ctx.overlayLookup?.modelNameByIR?.get(name);
239
+ let inferred =
240
+ adoptedModelNames.has(name) || discriminatedModelNames.has(name)
241
+ ? undefined
242
+ : ctx.overlayLookup?.modelNameByIR?.get(name);
230
243
  if (inferred) {
231
244
  if (inferred.startsWith('Serialized')) {
232
245
  const stripped = inferred.slice('Serialized'.length);
@@ -1,41 +1,6 @@
1
1
  import type { EmitterContext, Model, ResolvedOperation } from '@workos/oagen';
2
2
  import { enrichModelsFromSpec } from '../shared/model-utils.js';
3
-
4
- type OperationOverride = {
5
- methodName?: string;
6
- mountOn?: string;
7
- };
8
-
9
- const OPERATION_OVERRIDES: Record<string, OperationOverride> = {
10
- 'POST /organizations/{organizationId}/groups': {
11
- methodName: 'create_group',
12
- },
13
- 'GET /organizations/{organizationId}/groups': {
14
- methodName: 'list_groups',
15
- },
16
- 'GET /organizations/{organizationId}/groups/{groupId}': {
17
- methodName: 'get_group',
18
- },
19
- 'PATCH /organizations/{organizationId}/groups/{groupId}': {
20
- methodName: 'update_group',
21
- },
22
- 'DELETE /organizations/{organizationId}/groups/{groupId}': {
23
- methodName: 'delete_group',
24
- },
25
- 'POST /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
26
- methodName: 'add_organization_membership',
27
- },
28
- 'GET /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
29
- methodName: 'list_organization_memberships',
30
- },
31
- 'DELETE /organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}': {
32
- methodName: 'remove_organization_membership',
33
- },
34
- 'GET /user_management/organization_memberships/{omId}/groups': {
35
- methodName: 'list_groups_for_organization_membership',
36
- mountOn: 'UserManagement',
37
- },
38
- };
3
+ import { nodeOptions } from './options.js';
39
4
 
40
5
  const contextCache = new WeakMap<EmitterContext, EmitterContext>();
41
6
 
@@ -88,9 +53,11 @@ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext
88
53
  return next;
89
54
  }
90
55
 
56
+ const configOverrides = nodeOptions(ctx).operationOverrides ?? {};
57
+
91
58
  let opsChanged = false;
92
59
  const nextResolved = resolvedOperations.map((resolved) => {
93
- const override = OPERATION_OVERRIDES[operationKey(resolved)];
60
+ const override = configOverrides[operationKey(resolved)];
94
61
  if (!override) return resolved;
95
62
 
96
63
  const methodName = override.methodName ?? resolved.methodName;
@@ -1,5 +1,16 @@
1
1
  import type { EmitterContext } from '@workos/oagen';
2
2
 
3
+ export interface OperationOverride {
4
+ methodName?: string;
5
+ mountOn?: string;
6
+ optionsType?: string;
7
+ bodyFieldMap?: Record<string, string>;
8
+ returnType?: string;
9
+ returnDataProperty?: string;
10
+ returnTypeImports?: string[];
11
+ returnExpression?: string;
12
+ }
13
+
3
14
  export interface NodeEmitterOptions {
4
15
  /**
5
16
  * Existing SDK mode normally drops brand-new paths to avoid large accidental
@@ -21,6 +32,12 @@ export interface NodeEmitterOptions {
21
32
  * and fixtures remain hand-owned.
22
33
  */
23
34
  regenerateOwnedTests?: boolean;
35
+ /**
36
+ * Node-specific operation overrides keyed by "METHOD /path".
37
+ * Allows renaming methods or remounting operations for the Node SDK
38
+ * without affecting other languages.
39
+ */
40
+ operationOverrides?: Record<string, OperationOverride>;
24
41
  }
25
42
 
26
43
  export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
@@ -32,10 +49,21 @@ function normalizeServiceName(name: string): string {
32
49
  return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
33
50
  }
34
51
 
52
+ function ownedLookupNames(name: string): string[] {
53
+ const names = [name];
54
+ const baselineDirPrefix = '__baseline_dir__:';
55
+ if (name.startsWith(baselineDirPrefix)) {
56
+ names.push(name.slice(baselineDirPrefix.length));
57
+ }
58
+ return names;
59
+ }
60
+
35
61
  export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string | undefined>): boolean {
36
62
  const configured = nodeOptions(ctx).ownedServices ?? [];
37
63
  if (configured.length === 0) return false;
38
64
 
39
65
  const owned = new Set(configured.map(normalizeServiceName));
40
- return names.some((name) => name !== undefined && owned.has(normalizeServiceName(name)));
66
+ return names.some((name) =>
67
+ name !== undefined ? ownedLookupNames(name).some((candidate) => owned.has(normalizeServiceName(candidate))) : false,
68
+ );
41
69
  }