@workos/oagen-emitters 0.12.1 → 0.12.3

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 (45) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
@@ -34,11 +34,69 @@ export function wireFieldName(name: string): string {
34
34
  }
35
35
 
36
36
  /**
37
- * Wire/response interface name. Uses "Wire" suffix when the domain name
38
- * already ends in "Response" to avoid stuttering (e.g., FooResponseResponse).
37
+ * Active set of `Serialized${Name}` interfaces in the live SDK, harvested
38
+ * from `ctx.apiSurface` once per generation run. When non-empty, the
39
+ * legacy wire-naming scheme wins so existing hand-written serializer files
40
+ * continue to compile.
41
+ *
42
+ * Set by `index.ts` immediately after `getSurface(ctx)` runs.
43
+ */
44
+ let baselineSerializedNames: Set<string> = new Set();
45
+ export function setBaselineSerializedNames(names: Set<string>): void {
46
+ baselineSerializedNames = names;
47
+ }
48
+
49
+ /**
50
+ * Set of every interface name present in the baseline live SDK, regardless
51
+ * of naming convention. Used to detect single-form baselines (where one
52
+ * `*Response`-suffixed interface stands for both the domain and wire shape)
53
+ * so we don't synthesize a non-existent `*Wire` variant.
54
+ */
55
+ let baselineInterfaceNames: Set<string> = new Set();
56
+ export function setBaselineInterfaceNames(names: Set<string>): void {
57
+ baselineInterfaceNames = names;
58
+ }
59
+
60
+ /**
61
+ * IR models that belong to newly-adopted services should not be renamed by
62
+ * structural baseline matches from unrelated hand-written services.
63
+ */
64
+ let adoptedModelNames: Set<string> = new Set();
65
+ export function setAdoptedModelNames(names: Set<string>): void {
66
+ adoptedModelNames = names;
67
+ }
68
+ export function isAdoptedModelName(name: string): boolean {
69
+ return adoptedModelNames.has(name);
70
+ }
71
+
72
+ /**
73
+ * Wire/response interface name.
74
+ *
75
+ * Resolution order:
76
+ * 1. `Serialized${domainName}` if it exists in the baseline (legacy
77
+ * workos-node convention; lets hand-written serializer files keep
78
+ * compiling).
79
+ * 2. `${domainName}Wire` when the domain ends in `Response` AND the
80
+ * baseline actually has a `*Wire` interface (avoids
81
+ * `FooResponseResponse` stutter).
82
+ * 3. The bare `domainName` itself when it already ends in `Response` and
83
+ * no `*Wire` variant exists — this happens when the structural matcher
84
+ * maps an IR model to a baseline-wire-shaped interface
85
+ * (`AuditLogSchemaJson` → `AuditLogSchemaResponse`) and the baseline
86
+ * has no separate domain/wire split.
87
+ * 4. `${domainName}Response` for the standard fresh-emit case.
39
88
  */
40
89
  export function wireInterfaceName(domainName: string): string {
41
- return domainName.endsWith('Response') ? `${domainName}Wire` : `${domainName}Response`;
90
+ const serialized = `Serialized${domainName}`;
91
+ if (baselineSerializedNames.has(serialized)) return serialized;
92
+
93
+ if (domainName.endsWith('Response')) {
94
+ const wireForm = `${domainName}Wire`;
95
+ if (baselineInterfaceNames.has(wireForm)) return wireForm;
96
+ if (baselineInterfaceNames.has(domainName)) return domainName;
97
+ return wireForm;
98
+ }
99
+ return `${domainName}Response`;
42
100
  }
43
101
 
44
102
  /** kebab-case service directory name. */
@@ -53,17 +111,14 @@ export function servicePropertyName(name: string): string {
53
111
 
54
112
  /**
55
113
  * Resolve the effective service name, using the overlay-resolved class name
56
- * when available. This ensures directory names, file names, and property names
57
- * all derive from the same resolved name (e.g., "Mfa" instead of "MultiFactorAuth").
114
+ * when available.
58
115
  */
59
116
  export function resolveServiceName(service: Service, ctx: EmitterContext): string {
60
117
  return resolveClassName(service, ctx);
61
118
  }
62
119
 
63
120
  /**
64
- * Build a map from IR service name resolved service name.
65
- * Used to translate modelToService/enumToService map values to overlay-resolved
66
- * directory names when the code only has the IR service name string.
121
+ * Build a map from IR service name -> resolved service name.
67
122
  */
68
123
  export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
69
124
  const map = new Map<string, string>();
@@ -73,10 +128,7 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
73
128
  return map;
74
129
  }
75
130
 
76
- /**
77
- * Resolve the output directory for a service.
78
- * Mount rules already handle directory placement, so this is a simple kebab-case conversion.
79
- */
131
+ /** Resolve the output directory for a service. */
80
132
  export function resolveServiceDir(resolvedServiceName: string): string {
81
133
  return serviceDirName(resolvedServiceName);
82
134
  }
@@ -86,7 +138,6 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
86
138
  const lookup = buildResolvedLookup(ctx);
87
139
  const resolved = lookupMethodName(op, lookup);
88
140
  if (resolved) return toCamelCase(resolved);
89
- // Fallback to overlay, then spec-derived
90
141
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
91
142
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
92
143
  if (existing) return existing.methodName;
@@ -95,11 +146,9 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
95
146
 
96
147
  /** Resolve the SDK class name for a service, using resolved ops mountOn as canonical. */
97
148
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
98
- // Use resolved ops mountOn as canonical class name
99
149
  for (const r of ctx.resolvedOperations ?? []) {
100
150
  if (r.service.name === service.name) return r.mountOn;
101
151
  }
102
- // Fallback to overlay
103
152
  if (ctx.overlayLookup?.methodByOperation) {
104
153
  for (const op of service.operations) {
105
154
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
@@ -110,19 +159,48 @@ export function resolveClassName(service: Service, ctx: EmitterContext): string
110
159
  return toPascalCase(service.name);
111
160
  }
112
161
 
113
- /** Resolve the interface name for a model, checking overlay first.
162
+ /**
163
+ * Resolve the interface name for a model, checking overlay first.
114
164
  *
115
- * @param opts.skipTypeAlias - When true, skip apiSurface typeAlias resolution.
116
- * Use this for dedup models to ensure the file exports match the import
117
- * names (preserved files export the raw name, not the resolved alias).
165
+ * Lookup order:
166
+ * 1. `overlayLookup.interfaceByName` exact-name overrides from the live SDK.
167
+ * 2. `overlayLookup.modelNameByIR` structurally-inferred matches (e.g., IR
168
+ * `ValidateApiKey` with one field `value: string` → live SDK interface
169
+ * `ValidateApiKeyOptions`).
170
+ * 3. Type-alias resolution (when an alias points to an interface).
171
+ * 4. Suffix-fallback heuristic for the workos-node `*Options` convention:
172
+ * when the IR name `X` has no baseline match but `XOptions` does, use
173
+ * `XOptions`. The convention is widely used for request-body interfaces
174
+ * in workos-node (CreateOrganizationOptions, ListUsersOptions, etc.).
175
+ * 5. Default — clean and PascalCase the IR name.
118
176
  */
119
177
  export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: { skipTypeAlias?: boolean }): string {
120
178
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
121
179
  if (existing) return existing;
122
180
 
123
- // If the model name is a type alias that points to a canonical interface,
124
- // use the canonical name. This prevents the merger from generating unused
125
- // backward-compat aliases (e.g., `type FlagOwner = FeatureFlagOwner`).
181
+ let inferred = adoptedModelNames.has(name) ? undefined : ctx.overlayLookup?.modelNameByIR?.get(name);
182
+ if (inferred) {
183
+ if (inferred.startsWith('Serialized')) {
184
+ const stripped = inferred.slice('Serialized'.length);
185
+ if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
186
+ inferred = stripped;
187
+ }
188
+ }
189
+ // Structural matchers tend to lock onto the wire-shaped baseline
190
+ // interface (`AuditLogSchemaResponse`) because the IR carries
191
+ // snake_case fields. Prefer the corresponding domain name (without
192
+ // the `Response` suffix) when both exist in baseline — domain refs
193
+ // belong on the domain side, the wire/serialize path picks up the
194
+ // `*Response` variant via `wireInterfaceName`.
195
+ if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
196
+ const stripped = inferred.slice(0, -'Response'.length);
197
+ if (stripped && ctx.apiSurface.interfaces[stripped]) {
198
+ inferred = stripped;
199
+ }
200
+ }
201
+ return inferred;
202
+ }
203
+
126
204
  if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
127
205
  const alias = ctx.apiSurface.typeAliases[name] as { value?: string } | undefined;
128
206
  if (alias?.value && ctx.apiSurface.interfaces?.[alias.value]) {
@@ -130,9 +208,28 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
130
208
  }
131
209
  }
132
210
 
133
- // Strip spec-noise suffixes (e.g., "Dto") only for models without a
134
- // baseline. When an overlay exists (Scenario A), the overlay check above
135
- // handles existing models. New models (no overlay entry) get clean names.
211
+ // Suffix-fallback for the workos-node `*Options` convention, restricted to
212
+ // the case where the baseline `${name}Options` interface lives in the file
213
+ // we'd compute from the IR name itself (e.g. `validate-api-key.interface.ts`
214
+ // exports `ValidateApiKeyOptions`). Without this restriction, we'd re-point
215
+ // every IR name with an `*Options` baseline to a different file (e.g.
216
+ // `CreateOrganizationApiKey` → `…Options` lives in
217
+ // `create-organization-api-key-options.interface.ts`, NOT
218
+ // `create-organization-api-key.interface.ts`).
219
+ if (ctx.apiSurface?.interfaces) {
220
+ const ifaces = ctx.apiSurface.interfaces;
221
+ if (!ifaces[name]) {
222
+ const optionsCandidate = `${name}Options`;
223
+ const optionsInfo = ifaces[optionsCandidate] as { sourceFile?: string } | undefined;
224
+ if (optionsInfo?.sourceFile) {
225
+ const expectedStem = toKebabCase(stripUrnPrefix(name));
226
+ if (optionsInfo.sourceFile.endsWith(`/${expectedStem}.interface.ts`)) {
227
+ return optionsCandidate;
228
+ }
229
+ }
230
+ }
231
+ }
232
+
136
233
  const cleaned = ctx.apiSurface ? name : stripNoiseSuffixes(name);
137
234
  return toPascalCase(stripUrnPrefix(cleaned));
138
235
  }
@@ -0,0 +1,77 @@
1
+ import type { EmitterContext, ResolvedOperation } from '@workos/oagen';
2
+
3
+ type OperationOverride = {
4
+ methodName?: string;
5
+ mountOn?: string;
6
+ };
7
+
8
+ const OPERATION_OVERRIDES: Record<string, OperationOverride> = {
9
+ 'POST /organizations/{organizationId}/groups': {
10
+ methodName: 'create_group',
11
+ },
12
+ 'GET /organizations/{organizationId}/groups': {
13
+ methodName: 'list_groups',
14
+ },
15
+ 'GET /organizations/{organizationId}/groups/{groupId}': {
16
+ methodName: 'get_group',
17
+ },
18
+ 'PATCH /organizations/{organizationId}/groups/{groupId}': {
19
+ methodName: 'update_group',
20
+ },
21
+ 'DELETE /organizations/{organizationId}/groups/{groupId}': {
22
+ methodName: 'delete_group',
23
+ },
24
+ 'POST /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
25
+ methodName: 'add_organization_membership',
26
+ },
27
+ 'GET /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
28
+ methodName: 'list_organization_memberships',
29
+ },
30
+ 'DELETE /organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}': {
31
+ methodName: 'remove_organization_membership',
32
+ },
33
+ 'GET /user_management/organization_memberships/{omId}/groups': {
34
+ methodName: 'list_groups_for_organization_membership',
35
+ mountOn: 'UserManagement',
36
+ },
37
+ };
38
+
39
+ const contextCache = new WeakMap<EmitterContext, EmitterContext>();
40
+
41
+ function operationKey(resolved: ResolvedOperation): string {
42
+ return `${resolved.operation.httpMethod.toUpperCase()} ${resolved.operation.path}`;
43
+ }
44
+
45
+ export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext {
46
+ const cached = contextCache.get(ctx);
47
+ if (cached) return cached;
48
+
49
+ const resolvedOperations = ctx.resolvedOperations;
50
+ if (!resolvedOperations?.length) {
51
+ contextCache.set(ctx, ctx);
52
+ return ctx;
53
+ }
54
+
55
+ let changed = false;
56
+ const nextResolved = resolvedOperations.map((resolved) => {
57
+ const override = OPERATION_OVERRIDES[operationKey(resolved)];
58
+ if (!override) return resolved;
59
+
60
+ const methodName = override.methodName ?? resolved.methodName;
61
+ const mountOn = override.mountOn ?? resolved.mountOn;
62
+ if (methodName === resolved.methodName && mountOn === resolved.mountOn) {
63
+ return resolved;
64
+ }
65
+
66
+ changed = true;
67
+ return {
68
+ ...resolved,
69
+ methodName,
70
+ mountOn,
71
+ };
72
+ });
73
+
74
+ const next = changed ? { ...ctx, resolvedOperations: nextResolved } : ctx;
75
+ contextCache.set(ctx, next);
76
+ return next;
77
+ }
@@ -0,0 +1,41 @@
1
+ import type { EmitterContext } from '@workos/oagen';
2
+
3
+ export interface NodeEmitterOptions {
4
+ /**
5
+ * Existing SDK mode normally drops brand-new paths to avoid large accidental
6
+ * generations. When enabled, brand-new top-level service directories whose
7
+ * services are absent from the SDK are adopted from the spec.
8
+ */
9
+ adoptMissingServices?: boolean;
10
+ /**
11
+ * Existing tracked service directories to move under oagen ownership.
12
+ *
13
+ * Entries may be IR service names or resolved resource class names
14
+ * (for example, "Groups"). Owned service files are allowed to overwrite
15
+ * tracked, non-autogenerated files so the service can be migrated into the
16
+ * manifest one service at a time.
17
+ */
18
+ ownedServices?: string[];
19
+ /**
20
+ * Regenerate tests and fixtures for owned services. Non-owned service tests
21
+ * and fixtures remain hand-owned.
22
+ */
23
+ regenerateOwnedTests?: boolean;
24
+ }
25
+
26
+ export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
27
+ return ((ctx as EmitterContext & { emitterOptions?: Record<string, unknown> }).emitterOptions ??
28
+ {}) as NodeEmitterOptions;
29
+ }
30
+
31
+ function normalizeServiceName(name: string): string {
32
+ return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
33
+ }
34
+
35
+ export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string | undefined>): boolean {
36
+ const configured = nodeOptions(ctx).ownedServices ?? [];
37
+ if (configured.length === 0) return false;
38
+
39
+ const owned = new Set(configured.map(normalizeServiceName));
40
+ return names.some((name) => name !== undefined && owned.has(normalizeServiceName(name)));
41
+ }
@@ -14,8 +14,14 @@ import { fieldName } from './naming.js';
14
14
  * "/orgs" → `'orgs'`
15
15
  * "/orgs/{id}" → `` `orgs/${encodeURIComponent(id)}` ``
16
16
  * "/orgs/{id}/foo" → `` `orgs/${encodeURIComponent(id)}/foo` ``
17
+ *
18
+ * `paramNameMap` lets a caller override the local variable name used for a
19
+ * spec parameter — used by the options-object code path so the URL template
20
+ * references the SDK's public field name (e.g. `organizationMembershipId`)
21
+ * instead of the spec's path-param name (e.g. `omId`), avoiding a
22
+ * destructure rename in the method body.
17
23
  */
18
- export function buildNodePathExpression(rawPath: string): string {
24
+ export function buildNodePathExpression(rawPath: string, paramNameMap?: Map<string, string>): string {
19
25
  const segments = parsePathTemplate(rawPath);
20
26
  if (!hasPathParams(segments)) {
21
27
  return `'${rawPath}'`;
@@ -23,15 +29,16 @@ export function buildNodePathExpression(rawPath: string): string {
23
29
 
24
30
  let body = '';
25
31
  for (const seg of segments) {
26
- body += renderSegment(seg);
32
+ body += renderSegment(seg, paramNameMap);
27
33
  }
28
34
  return `\`${body}\``;
29
35
  }
30
36
 
31
- function renderSegment(seg: PathSegment): string {
37
+ function renderSegment(seg: PathSegment, paramNameMap?: Map<string, string>): string {
32
38
  if (seg.kind === 'literal') {
33
39
  // Template-literal-safe escapes: backtick, backslash, ${
34
40
  return seg.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
35
41
  }
36
- return `\${encodeURIComponent(${fieldName(seg.name)})}`;
42
+ const localName = paramNameMap?.get(seg.name) ?? fieldName(seg.name);
43
+ return `\${encodeURIComponent(${localName})}`;
37
44
  }