@workos/oagen-emitters 0.16.0 → 0.16.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-CpO8rePT.mjs} +1164 -490
- package/dist/plugin-CpO8rePT.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +264 -4
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +39 -3
- package/src/node/utils.ts +140 -22
- package/test/node/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +464 -0
- package/test/node/utils.test.ts +157 -2
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/src/node/resources.ts
CHANGED
|
@@ -225,7 +225,12 @@ function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string):
|
|
|
225
225
|
const methods = new Set<string>();
|
|
226
226
|
for (const block of content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)) {
|
|
227
227
|
for (const line of block[0].split('\n')) {
|
|
228
|
-
|
|
228
|
+
// Match the method name followed by `(` or by `<` — the latter covers
|
|
229
|
+
// generic methods (`getProfile<T extends ...>(...)`), including
|
|
230
|
+
// multi-line type-parameter lists where the line ends right after `<`.
|
|
231
|
+
// Matching only up to the opening bracket sidesteps balancing nested
|
|
232
|
+
// angle brackets like `<T extends Record<string, unknown> = ...>`.
|
|
233
|
+
const match = line.match(/^\s{2}(?:(?:public|private|protected)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*[<(]/);
|
|
229
234
|
if (match) methods.add(match[1]);
|
|
230
235
|
}
|
|
231
236
|
}
|
|
@@ -310,6 +315,18 @@ function renderOptionsParam(param: OptionsObjectParam): string {
|
|
|
310
315
|
return `options${param.optional ? '?' : ''}: ${param.type}`;
|
|
311
316
|
}
|
|
312
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Whether a baseline-derived type reference is a plain TypeScript identifier
|
|
320
|
+
* that can appear in a named import. Baseline (live-SDK) method params can
|
|
321
|
+
* carry inline object-literal TYPES (e.g. `{ intent: GenerateLinkIntent; ... }`);
|
|
322
|
+
* treating that literal text as a type NAME would slugify it into a filename
|
|
323
|
+
* and emit a named import of a brace-expression — both invalid. Literal types
|
|
324
|
+
* are kept inline in the emitted signature instead and never imported.
|
|
325
|
+
*/
|
|
326
|
+
function isValidTypeIdentifier(name: string): boolean {
|
|
327
|
+
return /^[A-Za-z_$][\w$]*$/.test(name);
|
|
328
|
+
}
|
|
329
|
+
|
|
313
330
|
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
314
331
|
// Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
|
|
315
332
|
// item types are extracted even when the hand-written code predates AutoPaginatable.
|
|
@@ -933,6 +950,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
933
950
|
|
|
934
951
|
const importedTypeNames = new Set<string>();
|
|
935
952
|
for (const optionType of optionObjectTypes) {
|
|
953
|
+
// Inline object-literal types from the baseline surface are rendered
|
|
954
|
+
// inline in the method signature — they have no importable name or file.
|
|
955
|
+
if (!isValidTypeIdentifier(optionType)) continue;
|
|
936
956
|
if (importedTypeNames.has(optionType)) continue;
|
|
937
957
|
importedTypeNames.add(optionType);
|
|
938
958
|
const sourceFile = baselineTypeSourceFile(ctx, optionType);
|
|
@@ -1675,7 +1695,17 @@ function renderOptionsObjectMethod(
|
|
|
1675
1695
|
const wireType = wireInterfaceName(itemType);
|
|
1676
1696
|
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
1677
1697
|
const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
1678
|
-
|
|
1698
|
+
// When path params are destructured out of the options object, the value
|
|
1699
|
+
// passed to AutoPaginatable (and to fetchAndDeserialize) is the REST
|
|
1700
|
+
// object — typed Omit<FullOptions, pathFields> — not the full options
|
|
1701
|
+
// interface. Declaring the full interface as the second type argument
|
|
1702
|
+
// fails TS2322 because the rest object lacks the required path-param
|
|
1703
|
+
// fields, so parameterize over the rest type actually passed.
|
|
1704
|
+
const restOptionsType =
|
|
1705
|
+
pathBindings.length > 0
|
|
1706
|
+
? `Omit<${optionParam.type}, ${pathBindings.map((b) => `'${b}'`).join(' | ')}>`
|
|
1707
|
+
: optionParam.type;
|
|
1708
|
+
const paginationType = needsWireSerializer ? 'PaginationOptions' : restOptionsType;
|
|
1679
1709
|
const returnType = needsWireSerializer
|
|
1680
1710
|
? `Promise<AutoPaginatable<${itemType}, ${paginationType}>>`
|
|
1681
1711
|
: (preferredBaselineReturnType(ctx, baselineMethod?.returnType) ??
|
|
@@ -1793,7 +1823,13 @@ function renderOptionsObjectMethod(
|
|
|
1793
1823
|
|
|
1794
1824
|
lines.push(` async ${method}(${renderOptionsParam(optionParam)}): ${methodReturnType} {`);
|
|
1795
1825
|
renderOptionsObjectDestructure(lines, pathBindings);
|
|
1796
|
-
|
|
1826
|
+
// POST/PUT/PATCH require the entity argument even without a request body —
|
|
1827
|
+
// the client signature is `post(path, entity, options?)`, so a body-less
|
|
1828
|
+
// call must still pass `{}` or the generated code fails with TS2554.
|
|
1829
|
+
const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
|
|
1830
|
+
lines.push(
|
|
1831
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${emptyBodyArg}${queryOptionsArg});`,
|
|
1832
|
+
);
|
|
1797
1833
|
if (override?.returnExpression) {
|
|
1798
1834
|
lines.push(` const result = ${returnExpr};`);
|
|
1799
1835
|
lines.push(` return ${override.returnExpression};`);
|
package/src/node/utils.ts
CHANGED
|
@@ -11,12 +11,15 @@ import { mapTypeRef } from './type-map.js';
|
|
|
11
11
|
import {
|
|
12
12
|
resolveInterfaceName,
|
|
13
13
|
fieldName,
|
|
14
|
+
fileName,
|
|
14
15
|
resolveServiceDir,
|
|
15
16
|
resolveMethodName,
|
|
16
17
|
buildServiceNameMap,
|
|
17
18
|
} from './naming.js';
|
|
18
|
-
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
19
|
-
import { assignModelsToServices } from '@workos/oagen';
|
|
19
|
+
import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
|
|
20
|
+
import { assignModelsToServices, collectModelRefs, collectFieldDependencies } from '@workos/oagen';
|
|
21
|
+
import { isNodeOwnedService } from './options.js';
|
|
22
|
+
import { liveSurfaceHasExistingSdk, liveSurfaceHasFile } from './live-surface.js';
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Compute a relative import path between two files within the generated SDK.
|
|
@@ -203,7 +206,7 @@ export function createServiceDirResolver(
|
|
|
203
206
|
serviceNameMap: Map<string, string>;
|
|
204
207
|
resolveDir: (irService: string | undefined) => string;
|
|
205
208
|
} {
|
|
206
|
-
const modelToService =
|
|
209
|
+
const modelToService = assignModelsToEmittableServices(models, services, ctx);
|
|
207
210
|
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
208
211
|
|
|
209
212
|
// Per-name → directory override, harvested from the live SDK surface.
|
|
@@ -212,25 +215,7 @@ export function createServiceDirResolver(
|
|
|
212
215
|
// baseline directory string (e.g., "user-management"). The override map is
|
|
213
216
|
// attached by tagging the model name with a directory prefix that bypasses
|
|
214
217
|
// the IR-service lookup. Concretely we keep a side map.
|
|
215
|
-
const baselineDirByModel =
|
|
216
|
-
const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
|
|
217
|
-
const sourceFile = info?.sourceFile;
|
|
218
|
-
if (!sourceFile) return;
|
|
219
|
-
const m = sourceFile.match(/^src\/([^/]+)\//);
|
|
220
|
-
if (!m) return;
|
|
221
|
-
baselineDirByModel.set(name, m[1]);
|
|
222
|
-
};
|
|
223
|
-
// Both interfaces and type aliases can shadow IR model names — e.g.
|
|
224
|
-
// `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
|
|
225
|
-
// canonical Role definition even though the IR represents Role as a model.
|
|
226
|
-
for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
|
|
227
|
-
recordSource(name, info as { sourceFile?: string });
|
|
228
|
-
}
|
|
229
|
-
for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
|
|
230
|
-
if (!baselineDirByModel.has(name)) {
|
|
231
|
-
recordSource(name, info as { sourceFile?: string });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
218
|
+
const baselineDirByModel = harvestBaselineDirByModel(ctx);
|
|
234
219
|
|
|
235
220
|
// Override modelToService for any IR model that has a baseline sourceFile.
|
|
236
221
|
// We invent a synthetic IR-service key that maps directly to the baseline
|
|
@@ -255,6 +240,139 @@ export function createServiceDirResolver(
|
|
|
255
240
|
return { modelToService, serviceNameMap, resolveDir };
|
|
256
241
|
}
|
|
257
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Map baseline interface / type-alias names to the top-level `src/<dir>/`
|
|
245
|
+
* their `sourceFile` lives in. Both kinds can shadow IR model names — e.g.
|
|
246
|
+
* `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
|
|
247
|
+
* canonical Role definition even though the IR represents Role as a model.
|
|
248
|
+
*/
|
|
249
|
+
function harvestBaselineDirByModel(ctx: EmitterContext): Map<string, string> {
|
|
250
|
+
const baselineDirByModel = new Map<string, string>();
|
|
251
|
+
const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
|
|
252
|
+
const sourceFile = info?.sourceFile;
|
|
253
|
+
if (!sourceFile) return;
|
|
254
|
+
const m = sourceFile.match(/^src\/([^/]+)\//);
|
|
255
|
+
if (!m) return;
|
|
256
|
+
baselineDirByModel.set(name, m[1]);
|
|
257
|
+
};
|
|
258
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
|
|
259
|
+
recordSource(name, info as { sourceFile?: string });
|
|
260
|
+
}
|
|
261
|
+
for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
|
|
262
|
+
if (!baselineDirByModel.has(name)) {
|
|
263
|
+
recordSource(name, info as { sourceFile?: string });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return baselineDirByModel;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `assignModelsToServices` plus an owned-service correction pass.
|
|
271
|
+
*
|
|
272
|
+
* The engine's assignment is first-reference-wins: a model referenced by both
|
|
273
|
+
* Organizations and AuditLogs lands in `organizations/` even when only
|
|
274
|
+
* AuditLogs is owned this run. Against an existing SDK, `applyLiveSurface`
|
|
275
|
+
* then drops the model file (a non-owned, non-adopted directory cannot
|
|
276
|
+
* receive new paths) while the owned resource still imports
|
|
277
|
+
* `../organizations/interfaces/<model>.interface` — an import that resolves
|
|
278
|
+
* to nothing (TS2307).
|
|
279
|
+
*
|
|
280
|
+
* The correction re-homes such models to the owned service that references
|
|
281
|
+
* them, so emission and import planning agree on a directory that is allowed
|
|
282
|
+
* to receive files. It only fires when:
|
|
283
|
+
* 1. `ownedServices` is configured and the run targets an existing SDK;
|
|
284
|
+
* 2. the model has no baseline `sourceFile` (otherwise the baseline-dir
|
|
285
|
+
* override keeps imports pointing at the on-disk location);
|
|
286
|
+
* 3. the model's computed interface path does not already exist on disk;
|
|
287
|
+
* 4. the assigned service is neither owned itself nor sharing a directory
|
|
288
|
+
* with an owned service.
|
|
289
|
+
*/
|
|
290
|
+
export function assignModelsToEmittableServices(
|
|
291
|
+
models: Model[],
|
|
292
|
+
services: Service[],
|
|
293
|
+
ctx?: EmitterContext,
|
|
294
|
+
): Map<string, string> {
|
|
295
|
+
const modelToService = assignModelsToServices(models, services, ctx?.modelHints);
|
|
296
|
+
if (ctx) {
|
|
297
|
+
reassignOwnedServiceDependencies(modelToService, models, services, ctx);
|
|
298
|
+
}
|
|
299
|
+
return modelToService;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function reassignOwnedServiceDependencies(
|
|
303
|
+
modelToService: Map<string, string>,
|
|
304
|
+
models: Model[],
|
|
305
|
+
services: Service[],
|
|
306
|
+
ctx: EmitterContext,
|
|
307
|
+
): void {
|
|
308
|
+
const serviceNameMap = buildServiceNameMap(services, ctx);
|
|
309
|
+
// Ownership is a property of the MOUNT target, not the IR service: an op
|
|
310
|
+
// can live on a non-owned IR service (e.g. Organizations, because its path
|
|
311
|
+
// starts with /organizations) while being mounted on an owned service via
|
|
312
|
+
// `resolvedOperations` (e.g. AuditLogs' retention endpoints). Walking only
|
|
313
|
+
// IR services misses such ops entirely, so the models they reference stay
|
|
314
|
+
// assigned to the unemittable IR directory and are never emitted anywhere.
|
|
315
|
+
// Regroup by mount target when resolved operations exist — same as
|
|
316
|
+
// `buildGeneratedResourceModelUsage` and `computeOwnedServiceDirs`.
|
|
317
|
+
const mountGroups = groupByMount(ctx);
|
|
318
|
+
const candidateServices: Service[] =
|
|
319
|
+
mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
|
|
320
|
+
const ownedServices = candidateServices.filter((s) => isNodeOwnedService(ctx, s.name, serviceNameMap.get(s.name)));
|
|
321
|
+
if (ownedServices.length === 0) return;
|
|
322
|
+
// Greenfield generation emits every directory; nothing is unemittable.
|
|
323
|
+
if (!liveSurfaceHasExistingSdk()) return;
|
|
324
|
+
|
|
325
|
+
const dirOf = (irService: string | undefined): string =>
|
|
326
|
+
irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
327
|
+
const ownedDirs = new Set(ownedServices.map((s) => dirOf(s.name)));
|
|
328
|
+
const baselineDirByModel = harvestBaselineDirByModel(ctx);
|
|
329
|
+
const modelsByName = new Map(models.map((m) => [m.name, m]));
|
|
330
|
+
|
|
331
|
+
for (const service of ownedServices) {
|
|
332
|
+
for (const name of collectServiceModelClosure(service, modelsByName)) {
|
|
333
|
+
if (!modelsByName.has(name)) continue;
|
|
334
|
+
if (baselineDirByModel.has(name)) continue; // declared in the baseline → on disk
|
|
335
|
+
const assigned = modelToService.get(name);
|
|
336
|
+
if (assigned && isNodeOwnedService(ctx, assigned, serviceNameMap.get(assigned))) continue;
|
|
337
|
+
const assignedDir = dirOf(assigned);
|
|
338
|
+
if (ownedDirs.has(assignedDir)) continue;
|
|
339
|
+
if (liveSurfaceHasFile(`src/${assignedDir}/interfaces/${fileName(name)}.interface.ts`)) continue;
|
|
340
|
+
modelToService.set(name, service.name);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Model names referenced by a service's operations, expanded through fields. */
|
|
346
|
+
function collectServiceModelClosure(service: Service, modelsByName: Map<string, Model>): Set<string> {
|
|
347
|
+
const referenced = new Set<string>();
|
|
348
|
+
const add = (ref: TypeRef | undefined): void => {
|
|
349
|
+
if (!ref) return;
|
|
350
|
+
for (const name of collectModelRefs(ref)) referenced.add(name);
|
|
351
|
+
};
|
|
352
|
+
for (const op of service.operations) {
|
|
353
|
+
add(op.requestBody);
|
|
354
|
+
add(op.response);
|
|
355
|
+
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
356
|
+
add(param.type);
|
|
357
|
+
}
|
|
358
|
+
if (op.pagination) add(op.pagination.itemType);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const queue = [...referenced];
|
|
362
|
+
while (queue.length > 0) {
|
|
363
|
+
const name = queue.pop()!;
|
|
364
|
+
const model = modelsByName.get(name);
|
|
365
|
+
if (!model) continue;
|
|
366
|
+
for (const dep of collectFieldDependencies(model).models) {
|
|
367
|
+
if (!referenced.has(dep)) {
|
|
368
|
+
referenced.add(dep);
|
|
369
|
+
queue.push(dep);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return referenced;
|
|
374
|
+
}
|
|
375
|
+
|
|
258
376
|
/**
|
|
259
377
|
* Check if baseline interface fields appear to contain generic type parameters.
|
|
260
378
|
*
|
package/test/node/enums.test.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Enum, Model } from '@workos/oagen';
|
|
3
3
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
4
|
import { generateEnums } from '../../src/node/enums.js';
|
|
5
|
+
import { nodeEmitter } from '../../src/node/index.js';
|
|
6
|
+
import { emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
5
11
|
|
|
6
12
|
const emptySpec: ApiSpec = {
|
|
7
13
|
name: 'Test',
|
|
@@ -126,3 +132,234 @@ describe('generateEnums', () => {
|
|
|
126
132
|
expect(result[0].content).toContain('/** @deprecated */');
|
|
127
133
|
});
|
|
128
134
|
});
|
|
135
|
+
|
|
136
|
+
describe('assignEnumsToServices owned-service dependency reassignment', () => {
|
|
137
|
+
it('follows a reassigned dependency model into the owned service', () => {
|
|
138
|
+
// The enum is referenced only through `AuditLogsRetention`, whose
|
|
139
|
+
// first-reference assignment is Organizations (unemittable this run).
|
|
140
|
+
// When the owned AuditLogs service pulls the model into `audit-logs/`,
|
|
141
|
+
// the enum must follow — otherwise the model file imports an enum file
|
|
142
|
+
// that is emitted nowhere.
|
|
143
|
+
const surface = emptyLiveSurface();
|
|
144
|
+
surface.files.add('src/workos.ts'); // existing SDK
|
|
145
|
+
setActiveLiveSurface(surface);
|
|
146
|
+
try {
|
|
147
|
+
const enums: Enum[] = [
|
|
148
|
+
{
|
|
149
|
+
name: 'RetentionPeriod',
|
|
150
|
+
values: [
|
|
151
|
+
{ name: 'THIRTY_DAYS', value: '30d' },
|
|
152
|
+
{ name: 'NINETY_DAYS', value: '90d' },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
const models = [
|
|
157
|
+
{
|
|
158
|
+
name: 'AuditLogsRetention',
|
|
159
|
+
fields: [
|
|
160
|
+
{
|
|
161
|
+
name: 'period',
|
|
162
|
+
type: { kind: 'enum' as const, name: 'RetentionPeriod', values: ['30d', '90d'] },
|
|
163
|
+
required: true,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const retentionOp = (name: string, path: string) => ({
|
|
169
|
+
name,
|
|
170
|
+
httpMethod: 'get' as const,
|
|
171
|
+
path,
|
|
172
|
+
pathParams: [],
|
|
173
|
+
queryParams: [],
|
|
174
|
+
headerParams: [],
|
|
175
|
+
response: { kind: 'model' as const, name: 'AuditLogsRetention' },
|
|
176
|
+
errors: [],
|
|
177
|
+
injectIdempotencyKey: false,
|
|
178
|
+
});
|
|
179
|
+
const services = [
|
|
180
|
+
{ name: 'Organizations', operations: [retentionOp('getRetention', '/organizations/{id}/retention')] },
|
|
181
|
+
{ name: 'AuditLogs', operations: [retentionOp('getAuditLogsRetention', '/audit_logs/retention')] },
|
|
182
|
+
];
|
|
183
|
+
const ctxOwned: EmitterContext = {
|
|
184
|
+
...ctx,
|
|
185
|
+
spec: { ...emptySpec, enums, models, services },
|
|
186
|
+
emitterOptions: { ownedServices: ['AuditLogs'] },
|
|
187
|
+
} as EmitterContext;
|
|
188
|
+
|
|
189
|
+
const result = generateEnums(enums, ctxOwned);
|
|
190
|
+
expect(result).toHaveLength(1);
|
|
191
|
+
expect(result[0].path).toBe('src/audit-logs/interfaces/retention-period.interface.ts');
|
|
192
|
+
} finally {
|
|
193
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('owned-service enum emission under the live-surface skip', () => {
|
|
199
|
+
function ownedDomainSpec(enums: Enum[], models: Model[]): ApiSpec {
|
|
200
|
+
return {
|
|
201
|
+
...emptySpec,
|
|
202
|
+
enums,
|
|
203
|
+
models,
|
|
204
|
+
services: [
|
|
205
|
+
{
|
|
206
|
+
name: 'OrganizationDomains',
|
|
207
|
+
operations: [
|
|
208
|
+
{
|
|
209
|
+
name: 'getOrganizationDomain',
|
|
210
|
+
httpMethod: 'get',
|
|
211
|
+
path: '/organization_domains/{id}',
|
|
212
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
213
|
+
queryParams: [],
|
|
214
|
+
headerParams: [],
|
|
215
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
216
|
+
errors: [],
|
|
217
|
+
injectIdempotencyKey: false,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const stateEnum: Enum = {
|
|
226
|
+
name: 'OrganizationDomainState',
|
|
227
|
+
values: [
|
|
228
|
+
{ name: 'VERIFIED', value: 'verified' },
|
|
229
|
+
{ name: 'PENDING', value: 'pending' },
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
const domainModel: Model = {
|
|
233
|
+
name: 'OrganizationDomain',
|
|
234
|
+
fields: [
|
|
235
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
236
|
+
{
|
|
237
|
+
name: 'state',
|
|
238
|
+
type: { kind: 'enum', name: 'OrganizationDomainState', values: ['verified', 'pending'] },
|
|
239
|
+
required: true,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
it('still emits the union module when the name is declared in a file the owned regeneration overwrites', () => {
|
|
245
|
+
// Real instance (OrganizationDomains rebuild, service OWNED): the
|
|
246
|
+
// on-disk organization-domain.interface.ts declared the enum names, so
|
|
247
|
+
// the live-surface skip suppressed emitting the canonical modules — but
|
|
248
|
+
// that very file was simultaneously being OVERWRITTEN by the owned
|
|
249
|
+
// regeneration, leaving the names declared nowhere.
|
|
250
|
+
const surface = emptyLiveSurface();
|
|
251
|
+
surface.files.add('src/workos.ts'); // existing SDK
|
|
252
|
+
surface.files.add('src/organization-domains/interfaces/organization-domain.interface.ts');
|
|
253
|
+
surface.interfaces.set('OrganizationDomainState', {
|
|
254
|
+
filePath: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
255
|
+
fields: new Set(),
|
|
256
|
+
});
|
|
257
|
+
setActiveLiveSurface(surface);
|
|
258
|
+
try {
|
|
259
|
+
const ctxOwned: EmitterContext = {
|
|
260
|
+
...ctx,
|
|
261
|
+
spec: ownedDomainSpec([stateEnum], [domainModel]),
|
|
262
|
+
emitterOptions: { ownedServices: ['OrganizationDomains'] },
|
|
263
|
+
} as EmitterContext;
|
|
264
|
+
|
|
265
|
+
const result = generateEnums([stateEnum], ctxOwned);
|
|
266
|
+
const enumFile = result.find(
|
|
267
|
+
(f) => f.path === 'src/organization-domains/interfaces/organization-domain-state.interface.ts',
|
|
268
|
+
);
|
|
269
|
+
expect(enumFile).toBeDefined();
|
|
270
|
+
expect(enumFile!.content).toContain('export const OrganizationDomainState = {');
|
|
271
|
+
expect(enumFile!.content).toContain('export type OrganizationDomainState =');
|
|
272
|
+
} finally {
|
|
273
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps the skip for non-owned services whose enum genuinely lives elsewhere', () => {
|
|
278
|
+
const surface = emptyLiveSurface();
|
|
279
|
+
surface.files.add('src/workos.ts');
|
|
280
|
+
surface.interfaces.set('OrganizationDomainState', {
|
|
281
|
+
filePath: 'src/common/interfaces/organization-domain-state.interface.ts',
|
|
282
|
+
fields: new Set(),
|
|
283
|
+
});
|
|
284
|
+
setActiveLiveSurface(surface);
|
|
285
|
+
try {
|
|
286
|
+
const ctxNotOwned: EmitterContext = {
|
|
287
|
+
...ctx,
|
|
288
|
+
spec: ownedDomainSpec([stateEnum], [domainModel]),
|
|
289
|
+
} as EmitterContext;
|
|
290
|
+
|
|
291
|
+
const result = generateEnums([stateEnum], ctxNotOwned);
|
|
292
|
+
expect(result).toHaveLength(0);
|
|
293
|
+
} finally {
|
|
294
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('emits the module, resolves the barrel export, and imports the name in the model file', () => {
|
|
299
|
+
// End-to-end shape of the OrganizationDomains failure: generated
|
|
300
|
+
// organization-domain.interface.ts used `OrganizationDomainState` with
|
|
301
|
+
// NO import, and interfaces/index.ts exported
|
|
302
|
+
// ./organization-domain-state.interface — a module no hook ever emitted.
|
|
303
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-enum-'));
|
|
304
|
+
try {
|
|
305
|
+
const ifaceDir = path.join(tmpRoot, 'src', 'organization-domains', 'interfaces');
|
|
306
|
+
fs.mkdirSync(ifaceDir, { recursive: true });
|
|
307
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
|
|
308
|
+
fs.writeFileSync(
|
|
309
|
+
path.join(ifaceDir, 'organization-domain.interface.ts'),
|
|
310
|
+
[
|
|
311
|
+
"export type OrganizationDomainState = 'verified' | 'pending';",
|
|
312
|
+
'',
|
|
313
|
+
'export interface OrganizationDomain {',
|
|
314
|
+
' id: string;',
|
|
315
|
+
' state: OrganizationDomainState;',
|
|
316
|
+
'}',
|
|
317
|
+
].join('\n'),
|
|
318
|
+
);
|
|
319
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
320
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
321
|
+
|
|
322
|
+
const spec = ownedDomainSpec([stateEnum], [domainModel]);
|
|
323
|
+
const runCtx = {
|
|
324
|
+
...ctx,
|
|
325
|
+
spec,
|
|
326
|
+
outputDir: tmpRoot,
|
|
327
|
+
emitterOptions: { ownedServices: ['OrganizationDomains'] },
|
|
328
|
+
} as EmitterContext;
|
|
329
|
+
|
|
330
|
+
const modelFiles = nodeEmitter.generateModels([domainModel], runCtx);
|
|
331
|
+
const enumFiles = nodeEmitter.generateEnums([stateEnum], runCtx);
|
|
332
|
+
const clientFiles = nodeEmitter.generateClient(spec, runCtx);
|
|
333
|
+
|
|
334
|
+
// The canonical union module IS emitted…
|
|
335
|
+
const enumPath = 'src/organization-domains/interfaces/organization-domain-state.interface.ts';
|
|
336
|
+
expect(enumFiles.some((f) => f.path === enumPath)).toBe(true);
|
|
337
|
+
|
|
338
|
+
// …the model file imports the name from it…
|
|
339
|
+
const modelFile = modelFiles.find(
|
|
340
|
+
(f) => f.path === 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
341
|
+
);
|
|
342
|
+
expect(modelFile).toBeDefined();
|
|
343
|
+
expect(modelFile!.content).toContain(
|
|
344
|
+
"import type { OrganizationDomainState } from './organization-domain-state.interface';",
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// …and the barrel export resolves to an emitted module instead of a
|
|
348
|
+
// phantom the import-invariant pass has to drop.
|
|
349
|
+
const barrel = clientFiles.find((f) => f.path === 'src/organization-domains/interfaces/index.ts');
|
|
350
|
+
expect(barrel).toBeDefined();
|
|
351
|
+
expect(barrel!.content).toContain("export * from './organization-domain-state.interface';");
|
|
352
|
+
|
|
353
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
354
|
+
try {
|
|
355
|
+
nodeEmitter.generateTests(spec, runCtx);
|
|
356
|
+
const dropped = warnSpy.mock.calls.filter((call) => String(call[0]).includes('dropped unresolvable'));
|
|
357
|
+
expect(dropped).toEqual([]);
|
|
358
|
+
} finally {
|
|
359
|
+
warnSpy.mockRestore();
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|