@workos/oagen-emitters 0.12.1 → 0.12.2

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/.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 +7 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
  12. package/dist/plugin-eCuvoL1T.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 +345 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +540 -351
  24. package/src/node/naming.ts +119 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/resources.ts +455 -46
  28. package/src/node/sdk-errors.ts +0 -16
  29. package/src/node/tests.ts +108 -83
  30. package/src/node/type-map.ts +40 -18
  31. package/src/node/utils.ts +89 -102
  32. package/src/node/wrappers.ts +0 -20
  33. package/test/node/client.test.ts +106 -1201
  34. package/test/node/enums.test.ts +59 -130
  35. package/test/node/errors.test.ts +2 -3
  36. package/test/node/live-surface.test.ts +240 -0
  37. package/test/node/models.test.ts +396 -765
  38. package/test/node/naming.test.ts +69 -234
  39. package/test/node/resources.test.ts +376 -2036
  40. package/test/node/tests.test.ts +119 -0
  41. package/test/node/type-map.test.ts +49 -54
  42. package/test/node/utils.test.ts +29 -80
  43. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  44. package/test/node/serializers.test.ts +0 -444
@@ -34,11 +34,66 @@ 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
+
69
+ /**
70
+ * Wire/response interface name.
71
+ *
72
+ * Resolution order:
73
+ * 1. `Serialized${domainName}` if it exists in the baseline (legacy
74
+ * workos-node convention; lets hand-written serializer files keep
75
+ * compiling).
76
+ * 2. `${domainName}Wire` when the domain ends in `Response` AND the
77
+ * baseline actually has a `*Wire` interface (avoids
78
+ * `FooResponseResponse` stutter).
79
+ * 3. The bare `domainName` itself when it already ends in `Response` and
80
+ * no `*Wire` variant exists — this happens when the structural matcher
81
+ * maps an IR model to a baseline-wire-shaped interface
82
+ * (`AuditLogSchemaJson` → `AuditLogSchemaResponse`) and the baseline
83
+ * has no separate domain/wire split.
84
+ * 4. `${domainName}Response` for the standard fresh-emit case.
39
85
  */
40
86
  export function wireInterfaceName(domainName: string): string {
41
- return domainName.endsWith('Response') ? `${domainName}Wire` : `${domainName}Response`;
87
+ const serialized = `Serialized${domainName}`;
88
+ if (baselineSerializedNames.has(serialized)) return serialized;
89
+
90
+ if (domainName.endsWith('Response')) {
91
+ const wireForm = `${domainName}Wire`;
92
+ if (baselineInterfaceNames.has(wireForm)) return wireForm;
93
+ if (baselineInterfaceNames.has(domainName)) return domainName;
94
+ return wireForm;
95
+ }
96
+ return `${domainName}Response`;
42
97
  }
43
98
 
44
99
  /** kebab-case service directory name. */
@@ -53,17 +108,14 @@ export function servicePropertyName(name: string): string {
53
108
 
54
109
  /**
55
110
  * 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").
111
+ * when available.
58
112
  */
59
113
  export function resolveServiceName(service: Service, ctx: EmitterContext): string {
60
114
  return resolveClassName(service, ctx);
61
115
  }
62
116
 
63
117
  /**
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.
118
+ * Build a map from IR service name -> resolved service name.
67
119
  */
68
120
  export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
69
121
  const map = new Map<string, string>();
@@ -73,10 +125,7 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
73
125
  return map;
74
126
  }
75
127
 
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
- */
128
+ /** Resolve the output directory for a service. */
80
129
  export function resolveServiceDir(resolvedServiceName: string): string {
81
130
  return serviceDirName(resolvedServiceName);
82
131
  }
@@ -86,7 +135,6 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
86
135
  const lookup = buildResolvedLookup(ctx);
87
136
  const resolved = lookupMethodName(op, lookup);
88
137
  if (resolved) return toCamelCase(resolved);
89
- // Fallback to overlay, then spec-derived
90
138
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
91
139
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
92
140
  if (existing) return existing.methodName;
@@ -95,11 +143,9 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
95
143
 
96
144
  /** Resolve the SDK class name for a service, using resolved ops mountOn as canonical. */
97
145
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
98
- // Use resolved ops mountOn as canonical class name
99
146
  for (const r of ctx.resolvedOperations ?? []) {
100
147
  if (r.service.name === service.name) return r.mountOn;
101
148
  }
102
- // Fallback to overlay
103
149
  if (ctx.overlayLookup?.methodByOperation) {
104
150
  for (const op of service.operations) {
105
151
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
@@ -110,19 +156,48 @@ export function resolveClassName(service: Service, ctx: EmitterContext): string
110
156
  return toPascalCase(service.name);
111
157
  }
112
158
 
113
- /** Resolve the interface name for a model, checking overlay first.
159
+ /**
160
+ * Resolve the interface name for a model, checking overlay first.
114
161
  *
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).
162
+ * Lookup order:
163
+ * 1. `overlayLookup.interfaceByName` exact-name overrides from the live SDK.
164
+ * 2. `overlayLookup.modelNameByIR` structurally-inferred matches (e.g., IR
165
+ * `ValidateApiKey` with one field `value: string` → live SDK interface
166
+ * `ValidateApiKeyOptions`).
167
+ * 3. Type-alias resolution (when an alias points to an interface).
168
+ * 4. Suffix-fallback heuristic for the workos-node `*Options` convention:
169
+ * when the IR name `X` has no baseline match but `XOptions` does, use
170
+ * `XOptions`. The convention is widely used for request-body interfaces
171
+ * in workos-node (CreateOrganizationOptions, ListUsersOptions, etc.).
172
+ * 5. Default — clean and PascalCase the IR name.
118
173
  */
119
174
  export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: { skipTypeAlias?: boolean }): string {
120
175
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
121
176
  if (existing) return existing;
122
177
 
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`).
178
+ let inferred = adoptedModelNames.has(name) ? undefined : ctx.overlayLookup?.modelNameByIR?.get(name);
179
+ if (inferred) {
180
+ if (inferred.startsWith('Serialized')) {
181
+ const stripped = inferred.slice('Serialized'.length);
182
+ if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
183
+ inferred = stripped;
184
+ }
185
+ }
186
+ // Structural matchers tend to lock onto the wire-shaped baseline
187
+ // interface (`AuditLogSchemaResponse`) because the IR carries
188
+ // snake_case fields. Prefer the corresponding domain name (without
189
+ // the `Response` suffix) when both exist in baseline — domain refs
190
+ // belong on the domain side, the wire/serialize path picks up the
191
+ // `*Response` variant via `wireInterfaceName`.
192
+ if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
193
+ const stripped = inferred.slice(0, -'Response'.length);
194
+ if (stripped && ctx.apiSurface.interfaces[stripped]) {
195
+ inferred = stripped;
196
+ }
197
+ }
198
+ return inferred;
199
+ }
200
+
126
201
  if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
127
202
  const alias = ctx.apiSurface.typeAliases[name] as { value?: string } | undefined;
128
203
  if (alias?.value && ctx.apiSurface.interfaces?.[alias.value]) {
@@ -130,9 +205,28 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
130
205
  }
131
206
  }
132
207
 
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.
208
+ // Suffix-fallback for the workos-node `*Options` convention, restricted to
209
+ // the case where the baseline `${name}Options` interface lives in the file
210
+ // we'd compute from the IR name itself (e.g. `validate-api-key.interface.ts`
211
+ // exports `ValidateApiKeyOptions`). Without this restriction, we'd re-point
212
+ // every IR name with an `*Options` baseline to a different file (e.g.
213
+ // `CreateOrganizationApiKey` → `…Options` lives in
214
+ // `create-organization-api-key-options.interface.ts`, NOT
215
+ // `create-organization-api-key.interface.ts`).
216
+ if (ctx.apiSurface?.interfaces) {
217
+ const ifaces = ctx.apiSurface.interfaces;
218
+ if (!ifaces[name]) {
219
+ const optionsCandidate = `${name}Options`;
220
+ const optionsInfo = ifaces[optionsCandidate] as { sourceFile?: string } | undefined;
221
+ if (optionsInfo?.sourceFile) {
222
+ const expectedStem = toKebabCase(stripUrnPrefix(name));
223
+ if (optionsInfo.sourceFile.endsWith(`/${expectedStem}.interface.ts`)) {
224
+ return optionsCandidate;
225
+ }
226
+ }
227
+ }
228
+ }
229
+
136
230
  const cleaned = ctx.apiSurface ? name : stripNoiseSuffixes(name);
137
231
  return toPascalCase(stripUrnPrefix(cleaned));
138
232
  }
@@ -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
+ }