@workos/oagen-emitters 0.16.0 → 0.17.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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -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-BLnR-FMi.mjs} +3687 -2393
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +271 -5
- 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 +166 -3
- package/src/node/utils.ts +140 -22
- package/src/rust/resources.ts +78 -29
- package/src/rust/tests.ts +15 -4
- package/src/shared/union-flatten.ts +201 -0
- 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 +611 -0
- package/test/node/utils.test.ts +157 -2
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Model, Field, TypeRef, UnionType } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten field-level discriminated unions into a single superset model for
|
|
5
|
+
* the flat-emit languages (Go, Kotlin, Node) that have no native sum type.
|
|
6
|
+
*
|
|
7
|
+
* Background: a property like `ApiKey.owner` is a discriminated `oneOf` whose
|
|
8
|
+
* variants are inline objects — `{ type: 'organization', id }` and
|
|
9
|
+
* `{ type: 'user', id, organization_id }`. The IR represents this as a `union`
|
|
10
|
+
* TypeRef referencing two variant models (`ApiKeyOwner`, `UserApiKeyOwner`).
|
|
11
|
+
* Languages that render such a union as "the first variant" — Go's
|
|
12
|
+
* `unionResolverName`, Kotlin's `baseName` — silently drop every field that
|
|
13
|
+
* only exists on a later variant, so `organization_id` disappears for
|
|
14
|
+
* user-scoped keys. (See the SDK compat report's owner-field note.)
|
|
15
|
+
*
|
|
16
|
+
* This transform, applied only by the flat-emit emitters, merges every
|
|
17
|
+
* variant's fields into the first variant (the union's canonical model),
|
|
18
|
+
* marks any field not shared by all variants optional, widens the
|
|
19
|
+
* discriminator property to the union of its per-variant literal values, and
|
|
20
|
+
* rewrites the field to a plain model ref to that canonical model. The result
|
|
21
|
+
* is one flat struct/data class/interface that carries every variant field,
|
|
22
|
+
* with the discriminator property telling callers which variant they hold —
|
|
23
|
+
* exactly how these emitters already flatten `allOf [base, oneOf]`
|
|
24
|
+
* discriminated bases (see `enrichModelsFromSpec`).
|
|
25
|
+
*
|
|
26
|
+
* Returns a new models array; the input models are not mutated. Union-emitting
|
|
27
|
+
* languages (Python, PHP, Rust, Ruby, .NET) must NOT call this — they emit a
|
|
28
|
+
* real discriminated union and lose nothing.
|
|
29
|
+
*/
|
|
30
|
+
export function flattenDiscriminatedUnionFields(models: Model[]): Model[] {
|
|
31
|
+
const byName = new Map(models.map((m) => [m.name, m]));
|
|
32
|
+
// Canonical (first-variant) model name → its merged superset field list.
|
|
33
|
+
const mergedFieldsByCanonical = new Map<string, Field[]>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether a union is a flat-flattenable discriminated union of inline
|
|
37
|
+
* object variants. When it is, record the merged field set for its canonical
|
|
38
|
+
* model and return that model's name; otherwise return null.
|
|
39
|
+
*/
|
|
40
|
+
function planUnion(union: UnionType): string | null {
|
|
41
|
+
if (!union.discriminator) return null;
|
|
42
|
+
|
|
43
|
+
const variantNames = union.variants.map((v) => (v.kind === 'model' ? v.name : null));
|
|
44
|
+
// Require that *every* variant is a model ref (the inline-object oneOf
|
|
45
|
+
// shape). Untagged unions of primitives (e.g. AuditLogEvent actor
|
|
46
|
+
// metadata: string | number | boolean) carry no discriminator and never
|
|
47
|
+
// reach here, but guard anyway.
|
|
48
|
+
if (variantNames.length < 2 || variantNames.some((n) => n === null)) return null;
|
|
49
|
+
|
|
50
|
+
const variantModels = (variantNames as string[]).map((n) => byName.get(n));
|
|
51
|
+
// Every variant must resolve to a concrete data model — not a discriminator
|
|
52
|
+
// dispatcher (empty-field base with its own `discriminator`) and not a
|
|
53
|
+
// fieldless placeholder. This keeps event-style unions out of scope.
|
|
54
|
+
if (variantModels.some((m) => !m || (m as { discriminator?: unknown }).discriminator || m.fields.length === 0)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const canonical = (variantNames as string[])[0];
|
|
59
|
+
const merged = mergeVariantFields(variantModels as Model[], union.discriminator.property);
|
|
60
|
+
|
|
61
|
+
// The merge map is keyed by the first-variant model name. The same union
|
|
62
|
+
// referenced by several container fields re-plans to an identical merge
|
|
63
|
+
// (harmless). But two *distinct* unions that share a first variant would
|
|
64
|
+
// each want a different superset on that one model — pass 2 can apply only
|
|
65
|
+
// one, silently dropping the other's fields. Fail loudly instead; the spec
|
|
66
|
+
// must disambiguate (rename one union's leading variant).
|
|
67
|
+
const existing = mergedFieldsByCanonical.get(canonical);
|
|
68
|
+
if (existing && fieldListSignature(existing) !== fieldListSignature(merged)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`flattenDiscriminatedUnionFields: model "${canonical}" is the first variant of two distinct ` +
|
|
71
|
+
'discriminated unions that merge to different field sets. Flattening both onto one model would ' +
|
|
72
|
+
'silently drop fields; disambiguate the variants in the spec (rename the leading variant of one union).',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
mergedFieldsByCanonical.set(canonical, merged);
|
|
76
|
+
return canonical;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Rewrite a TypeRef, collapsing flattenable unions to a canonical model ref. */
|
|
80
|
+
function rewriteRef(ref: TypeRef): TypeRef {
|
|
81
|
+
switch (ref.kind) {
|
|
82
|
+
case 'union': {
|
|
83
|
+
const canonical = planUnion(ref);
|
|
84
|
+
return canonical ? { kind: 'model', name: canonical } : ref;
|
|
85
|
+
}
|
|
86
|
+
case 'nullable': {
|
|
87
|
+
// Preserve reference identity when nothing inside changed, so pass 1's
|
|
88
|
+
// `type === field.type` check doesn't flag (and rebuild) union-free fields.
|
|
89
|
+
const inner = rewriteRef(ref.inner);
|
|
90
|
+
return inner === ref.inner ? ref : { kind: 'nullable', inner };
|
|
91
|
+
}
|
|
92
|
+
case 'array': {
|
|
93
|
+
const items = rewriteRef(ref.items);
|
|
94
|
+
return items === ref.items ? ref : { kind: 'array', items };
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
return ref;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass 1: rewrite container fields, recording the merges to apply in pass 2.
|
|
102
|
+
const rewritten = models.map((model) => {
|
|
103
|
+
let changed = false;
|
|
104
|
+
const fields = model.fields.map((field) => {
|
|
105
|
+
const type = rewriteRef(field.type);
|
|
106
|
+
if (type === field.type) return field;
|
|
107
|
+
changed = true;
|
|
108
|
+
return { ...field, type };
|
|
109
|
+
});
|
|
110
|
+
return changed ? { ...model, fields } : model;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (mergedFieldsByCanonical.size === 0) return models;
|
|
114
|
+
|
|
115
|
+
// Pass 2: replace each canonical variant model with its merged superset.
|
|
116
|
+
return rewritten.map((model) => {
|
|
117
|
+
const merged = mergedFieldsByCanonical.get(model.name);
|
|
118
|
+
return merged ? { ...model, fields: merged } : model;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the merged field list for a discriminated union's variant models.
|
|
124
|
+
*
|
|
125
|
+
* - A field is required only when present-and-required in *every* variant; a
|
|
126
|
+
* field missing from some variant (e.g. the user variant's `organization_id`)
|
|
127
|
+
* becomes optional.
|
|
128
|
+
* - The discriminator property is widened to the union of its per-variant
|
|
129
|
+
* literal values (`'organization' | 'user'`) so it isn't pinned to the first
|
|
130
|
+
* variant's constant. (Flat-emit type maps collapse a single-typed literal
|
|
131
|
+
* union to a plain string, so this is a no-op for Go/Kotlin and a precise
|
|
132
|
+
* `'organization' | 'user'` for Node.)
|
|
133
|
+
* - Field order follows the first variant, then newly-seen fields from later
|
|
134
|
+
* variants.
|
|
135
|
+
*/
|
|
136
|
+
function mergeVariantFields(variants: Model[], discriminatorProp: string): Field[] {
|
|
137
|
+
const total = variants.length;
|
|
138
|
+
const order: string[] = [];
|
|
139
|
+
const defByName = new Map<string, Field>();
|
|
140
|
+
const presence = new Map<string, number>();
|
|
141
|
+
const requiredCount = new Map<string, number>();
|
|
142
|
+
|
|
143
|
+
for (const variant of variants) {
|
|
144
|
+
for (const field of variant.fields) {
|
|
145
|
+
const seen = defByName.get(field.name);
|
|
146
|
+
if (!seen) {
|
|
147
|
+
defByName.set(field.name, field);
|
|
148
|
+
order.push(field.name);
|
|
149
|
+
} else if (field.name !== discriminatorProp && !sameTypeRef(seen.type, field.type)) {
|
|
150
|
+
// Only the first-seen definition is kept, so a shared field whose type
|
|
151
|
+
// differs across variants would be merged with the wrong type for the
|
|
152
|
+
// other variants. The discriminator is exempt (it is widened below).
|
|
153
|
+
throw new Error(
|
|
154
|
+
`flattenDiscriminatedUnionFields: field "${field.name}" has conflicting types across variants ` +
|
|
155
|
+
'of a discriminated union; a flat superset model cannot represent both. Align the field type ' +
|
|
156
|
+
'across variants in the spec.',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
presence.set(field.name, (presence.get(field.name) ?? 0) + 1);
|
|
160
|
+
if (field.required) requiredCount.set(field.name, (requiredCount.get(field.name) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return order.map((name) => {
|
|
165
|
+
const def = defByName.get(name)!;
|
|
166
|
+
|
|
167
|
+
if (name === discriminatorProp) {
|
|
168
|
+
const literals = dedupeLiteralTypes(
|
|
169
|
+
variants.map((v) => v.fields.find((f) => f.name === name)?.type).filter((t): t is TypeRef => t != null),
|
|
170
|
+
);
|
|
171
|
+
const type: TypeRef = literals.length > 1 ? { kind: 'union', variants: literals } : (literals[0] ?? def.type);
|
|
172
|
+
return { ...def, type, required: presence.get(name) === total };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const required = presence.get(name) === total && requiredCount.get(name) === total;
|
|
176
|
+
return required === def.required ? def : { ...def, required };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Structural equality of two TypeRefs (IR refs have a stable, deterministic shape). */
|
|
181
|
+
function sameTypeRef(a: TypeRef, b: TypeRef): boolean {
|
|
182
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Stable signature of a merged field list, used to detect canonical collisions. */
|
|
186
|
+
function fieldListSignature(fields: Field[]): string {
|
|
187
|
+
return JSON.stringify(fields.map((f) => [f.name, f.required, f.type]));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Deduplicate literal TypeRefs by value, preserving first-seen order. */
|
|
191
|
+
function dedupeLiteralTypes(types: TypeRef[]): TypeRef[] {
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const out: TypeRef[] = [];
|
|
194
|
+
for (const t of types) {
|
|
195
|
+
const key = t.kind === 'literal' ? `lit:${String(t.value)}` : JSON.stringify(t);
|
|
196
|
+
if (seen.has(key)) continue;
|
|
197
|
+
seen.add(key);
|
|
198
|
+
out.push(t);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
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
|
+
});
|