@workos/oagen-emitters 0.18.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-DAa-HsN5.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.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -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
@@ -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
+ }
@@ -303,9 +303,26 @@ function optionsObjectInfo(
303
303
 
304
304
  if (!operationHasOptionsInput(op, plan, resolvedOp)) return undefined;
305
305
 
306
+ const resolvedService = resolveResourceClassName(service, ctx);
307
+ let optionsType = methodOptionsName(method, resolvedService);
308
+ // A bare `${Method}Options` name (e.g. `GetGroupOptions`) can collide with a
309
+ // same-named baseline type owned by a DIFFERENT service (for example the
310
+ // Groups module's `GetGroupOptions`, shaped `{ organizationId, groupId }`).
311
+ // Reusing it by name would import a foreign shape. When the existing
312
+ // declaration lives in another service directory, qualify the name with the
313
+ // service (mirroring the `list` -> `${Service}ListOptions` rule) so this
314
+ // service generates and imports its own options type.
315
+ if (method !== 'list') {
316
+ const baselineFile = baselineTypeSourceFile(ctx, optionsType);
317
+ const baselineDir = baselineFile?.match(/^src\/([^/]+)\//)?.[1];
318
+ if (baselineDir && baselineDir !== resolveResourceDir(service, ctx)) {
319
+ optionsType = `${toPascalCase(resolvedService)}${toPascalCase(method)}Options`;
320
+ }
321
+ }
322
+
306
323
  return {
307
324
  name: 'options',
308
- type: methodOptionsName(method, resolveResourceClassName(service, ctx)),
325
+ type: optionsType,
309
326
  optional: optionsObjectShouldBeOptional(op, plan, resolvedOp),
310
327
  generated: true,
311
328
  };
@@ -1194,7 +1211,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
1194
1211
  const baselineMethod = baselineMethodFor(service, method, ctx);
1195
1212
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolved);
1196
1213
  if (plan.isPaginated) {
1197
- const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
1214
+ // Skip deprecated query params: the typed options interface omits them
1215
+ // (the curated baseline drops deprecated params), so the wire serializer
1216
+ // must not reference a field that does not exist on the options type.
1217
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name) && !p.deprecated);
1198
1218
  if (extraParams.length > 0) {
1199
1219
  const optionsName = optionInfo?.type ?? paginatedOptionsName(method, resolvedName);
1200
1220
 
package/src/node/tests.ts CHANGED
@@ -670,9 +670,11 @@ function buildOptionsObjectTestArg(
670
670
  if (plan.isPaginated) {
671
671
  entries.push("order: 'desc'");
672
672
  }
673
- const queryParams = plan.isPaginated
674
- ? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
675
- : op.queryParams;
673
+ const queryParams = (
674
+ plan.isPaginated
675
+ ? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
676
+ : op.queryParams
677
+ ).filter((param) => !param.deprecated);
676
678
  for (const param of queryParams) {
677
679
  const localName = fieldName(param.name);
678
680
  const value = queryParamTestValue(param, modelMap);