@workos/oagen-emitters 0.19.0 → 0.19.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/{plugin-BXDPA9pJ.mjs → plugin-DXIciTnN.mjs} +535 -96
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/tests.ts +1 -1
- package/src/go/enums.ts +91 -18
- package/src/go/fixtures.ts +25 -3
- package/src/go/flat-merge.ts +253 -0
- package/src/go/models.ts +85 -20
- package/src/go/tests.ts +4 -2
- package/src/kotlin/models.ts +36 -6
- package/src/kotlin/tests.ts +36 -1
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +118 -34
- package/src/python/tests.ts +28 -9
- package/src/ruby/tests.ts +35 -2
- package/src/rust/enums.ts +29 -15
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +26 -8
- package/src/rust/tests.ts +1 -1
- package/src/shared/resolved-ops.ts +57 -0
- package/test/dotnet/scoped-aggregates.test.ts +247 -0
- package/test/go/scoping.test.ts +324 -0
- package/test/kotlin/models.test.ts +74 -0
- package/test/kotlin/tests.test.ts +33 -0
- package/test/python/scoped-aggregates.test.ts +205 -0
- package/test/ruby/tests.test.ts +130 -0
- package/test/rust/fixtures.test.ts +13 -7
- package/dist/plugin-BXDPA9pJ.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-DXIciTnN.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
package/src/dotnet/fixtures.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
1
|
+
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { fixtureFileName, domainFieldName } from './naming.js';
|
|
3
3
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
4
4
|
import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
|
|
5
|
+
import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Prefix the per-model fixture path with the .NET test project layout so the
|
|
9
|
+
* scoped-run gate can compare it against the prefixed paths recorded in the
|
|
10
|
+
* prior manifest. Must mirror `TEST_PREFIX` in index.ts; the fixture path
|
|
11
|
+
* itself (`testdata/<name>.json`) is what `prefixTestPaths` later prepends to.
|
|
12
|
+
*/
|
|
13
|
+
const TEST_PREFIX = 'test/WorkOSTests/';
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* Prefix mapping for generating realistic ID fixture values.
|
|
@@ -25,11 +34,14 @@ export const ID_PREFIXES: Record<string, string> = {
|
|
|
25
34
|
/**
|
|
26
35
|
* Generate JSON fixture files for test data.
|
|
27
36
|
*/
|
|
28
|
-
export function generateFixtures(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
export function generateFixtures(
|
|
38
|
+
spec: {
|
|
39
|
+
models: Model[];
|
|
40
|
+
enums: Enum[];
|
|
41
|
+
services: any[];
|
|
42
|
+
},
|
|
43
|
+
ctx: EmitterContext,
|
|
44
|
+
): { path: string; content: string }[] {
|
|
33
45
|
if (spec.models.length === 0) return [];
|
|
34
46
|
|
|
35
47
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
@@ -50,10 +62,19 @@ export function generateFixtures(spec: {
|
|
|
50
62
|
if (isListMetadataModel(model)) continue;
|
|
51
63
|
if (isListWrapperModel(model) && !nonPaginatedWrapperRefs.has(model.name)) continue;
|
|
52
64
|
|
|
65
|
+
// Scoped-run gate: only emit a per-model fixture whose file will exist on
|
|
66
|
+
// disk after the run (in-scope, or already present from a prior manifest).
|
|
67
|
+
// A brand-new out-of-scope model's fixture would be a stray file for a
|
|
68
|
+
// model no in-scope test can reference; a renamed/removed-but-on-disk one
|
|
69
|
+
// is retained. Full runs emit everything. `relPath` carries the
|
|
70
|
+
// `test/WorkOSTests/` prefix so it matches the prefixed prior-manifest paths.
|
|
71
|
+
const fixturePath = `testdata/${fixtureFileName(model.name)}.json`;
|
|
72
|
+
if (!fileExistsAfterRun(`${TEST_PREFIX}${fixturePath}`, isModelInScope(model.name, ctx), ctx)) continue;
|
|
73
|
+
|
|
53
74
|
const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
|
|
54
75
|
|
|
55
76
|
files.push({
|
|
56
|
-
path:
|
|
77
|
+
path: fixturePath,
|
|
57
78
|
content: JSON.stringify(fixture, null, 2),
|
|
58
79
|
});
|
|
59
80
|
|
package/src/dotnet/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { generateClient } from './client.js';
|
|
|
19
19
|
import { generateTests } from './tests.js';
|
|
20
20
|
import { buildOperationsMap } from './manifest.js';
|
|
21
21
|
import { generateWrapperOptionsClasses } from './wrappers.js';
|
|
22
|
-
import { groupByMount } from '../shared/resolved-ops.js';
|
|
22
|
+
import { groupByMount, isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
|
|
23
23
|
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
24
24
|
import { discriminatedUnions, resolveModelName } from './type-map.js';
|
|
25
25
|
import { modelClassName } from './naming.js';
|
|
@@ -66,6 +66,28 @@ function prefixTestPaths(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
66
66
|
return files;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Scoped-run gate for a discriminated-union variant referenced by a generated
|
|
71
|
+
* converter (the event-registry / polymorphic dispatch aggregate). A converter
|
|
72
|
+
* may only name a variant whose per-model `Entities/<Class>.cs` file will exist
|
|
73
|
+
* on disk after the run — otherwise the `new <Class>()` / `ToObject<<Class>>`
|
|
74
|
+
* arm references a type whose file is never emitted (CS0246). The set that
|
|
75
|
+
* exists after the run is: in-scope variants (emitted this run) ∪ variants
|
|
76
|
+
* already on disk from a prior manifest (scoped runs never prune), so a
|
|
77
|
+
* renamed/removed-but-still-on-disk variant is RETAINED while a brand-new
|
|
78
|
+
* out-of-scope variant is EXCLUDED. A full run includes everything.
|
|
79
|
+
*
|
|
80
|
+
* The variant name is resolved through the model-alias map first, exactly as
|
|
81
|
+
* the converter emits it (`modelClassName(resolveModelName(name))`), and the
|
|
82
|
+
* candidate path carries the `src/WorkOS.net/` prefix so it matches the
|
|
83
|
+
* prefixed paths recorded in `priorTargetManifestPaths`.
|
|
84
|
+
*/
|
|
85
|
+
function variantFileExistsAfterRun(variantModelName: string, ctx: EmitterContext): boolean {
|
|
86
|
+
const canonical = resolveModelName(variantModelName);
|
|
87
|
+
const relPath = `${SRC_PREFIX}Entities/${modelClassName(canonical)}.cs`;
|
|
88
|
+
return fileExistsAfterRun(relPath, isModelInScope(canonical, ctx), ctx);
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
export const dotnetEmitter: Emitter = {
|
|
70
92
|
language: 'dotnet',
|
|
71
93
|
|
|
@@ -146,6 +168,11 @@ export const dotnetEmitter: Emitter = {
|
|
|
146
168
|
lines.push(' switch (discriminatorValue)');
|
|
147
169
|
lines.push(' {');
|
|
148
170
|
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
171
|
+
// Scoped-run gate: only dispatch to a variant whose `Entities/*.cs`
|
|
172
|
+
// file will exist after the run. A brand-new out-of-scope variant is
|
|
173
|
+
// skipped (its `.cs` is never emitted ⇒ CS0246); unmatched discriminator
|
|
174
|
+
// values fall through to the `default` arm below.
|
|
175
|
+
if (!variantFileExistsAfterRun(modelName, c)) continue;
|
|
149
176
|
const csName = modelClassName(resolveModelName(modelName));
|
|
150
177
|
lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
|
|
151
178
|
}
|
|
@@ -177,6 +204,12 @@ export const dotnetEmitter: Emitter = {
|
|
|
177
204
|
// CanWrite is false so serialization uses the default path and never
|
|
178
205
|
// re-enters WriteJson.
|
|
179
206
|
for (const [baseName, disc] of modelDiscriminators) {
|
|
207
|
+
// Skip the converter entirely when the base model's own `Entities/*.cs`
|
|
208
|
+
// won't exist after a scoped run (brand-new out-of-scope base): its
|
|
209
|
+
// `new <baseClass>()` default arm and `[JsonConverter]` attribute would
|
|
210
|
+
// both dangle. When the base is already on disk we still emit it — the
|
|
211
|
+
// untouched base class references this converter by name.
|
|
212
|
+
if (!variantFileExistsAfterRun(baseName, c)) continue;
|
|
180
213
|
const baseClass = modelClassName(baseName);
|
|
181
214
|
const converterName = `${baseClass}DiscriminatorConverter`;
|
|
182
215
|
const lines: string[] = [];
|
|
@@ -210,6 +243,14 @@ export const dotnetEmitter: Emitter = {
|
|
|
210
243
|
lines.push(' switch (discriminatorValue)');
|
|
211
244
|
lines.push(' {');
|
|
212
245
|
for (const [value, variantModelName] of Object.entries(disc.mapping)) {
|
|
246
|
+
// Scoped-run gate: only construct a variant whose `Entities/*.cs` file
|
|
247
|
+
// will exist after the run. This is the event-registry aggregate
|
|
248
|
+
// (`EventSchema` enumerates every event payload model): a brand-new
|
|
249
|
+
// out-of-scope event type is skipped (its `.cs` is never emitted ⇒
|
|
250
|
+
// CS0246), while a renamed/removed-but-still-on-disk one is retained.
|
|
251
|
+
// Unmatched discriminator values fall through to the `default` arm,
|
|
252
|
+
// which deserializes into the base class.
|
|
253
|
+
if (!variantFileExistsAfterRun(variantModelName, c)) continue;
|
|
213
254
|
const csName = modelClassName(variantModelName);
|
|
214
255
|
lines.push(` case "${value}": target = new ${csName}(); break;`);
|
|
215
256
|
}
|
package/src/dotnet/tests.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
30
30
|
const files: GeneratedFile[] = [];
|
|
31
31
|
|
|
32
32
|
// Generate fixture JSON files
|
|
33
|
-
const fixtures = generateFixtures(spec);
|
|
33
|
+
const fixtures = generateFixtures(spec, ctx);
|
|
34
34
|
for (const fixture of fixtures) {
|
|
35
35
|
files.push({
|
|
36
36
|
path: fixture.path,
|
package/src/go/enums.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
2
|
import { walkTypeRef } from '@workos/oagen';
|
|
3
3
|
import { className } from './naming.js';
|
|
4
|
+
import { isEnumInScope, isScopedRun } from '../shared/resolved-ops.js';
|
|
5
|
+
import { reconcileFlatBlocks, readPriorFile, type NamedBlock } from './flat-merge.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Generate Go typed string enum constants from IR Enum definitions.
|
|
@@ -16,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
16
18
|
if (enums.length === 0) return [];
|
|
17
19
|
|
|
18
20
|
const aliasOf = collectEnumAliasOf(enums);
|
|
21
|
+
// An in-scope enum alias emits `type Alias = Canonical` against the current
|
|
22
|
+
// spec; the canonical may itself be out of scope and brand-new, which the
|
|
23
|
+
// reconciler would drop, leaving the alias dangling. Force-retain any
|
|
24
|
+
// canonical referenced by an in-scope alias.
|
|
25
|
+
const forcedCanonicals = new Set<string>();
|
|
26
|
+
if (isScopedRun(ctx)) {
|
|
27
|
+
for (const [aliasName, canonical] of aliasOf) {
|
|
28
|
+
if (isEnumInScope(aliasName, ctx)) forcedCanonicals.add(canonical);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
19
31
|
const files: GeneratedFile[] = [];
|
|
20
32
|
|
|
21
33
|
// Group all enums into a single file per SDK
|
|
@@ -23,6 +35,12 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
23
35
|
lines.push(`package ${ctx.namespace}`);
|
|
24
36
|
lines.push('');
|
|
25
37
|
|
|
38
|
+
// Build one NamedBlock per emitted enum type so a scoped run can reconcile
|
|
39
|
+
// them against the prior enums.go (drop brand-new out-of-scope enums, retain
|
|
40
|
+
// renamed/removed ones still referenced by un-regenerated code). A full run
|
|
41
|
+
// emits every block unchanged.
|
|
42
|
+
const enumBlocks: NamedBlock[] = [];
|
|
43
|
+
|
|
26
44
|
for (const enumDef of enums) {
|
|
27
45
|
// If this enum is an alias, emit a simple type alias
|
|
28
46
|
const canonicalName = aliasOf.get(enumDef.name);
|
|
@@ -33,9 +51,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
33
51
|
// enums from enrichModelsFromSpec whose underscore names collapse to the
|
|
34
52
|
// same PascalCase as the original enum).
|
|
35
53
|
if (aliasType === canonicalType) continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
enumBlocks.push({
|
|
55
|
+
names: [aliasType],
|
|
56
|
+
text: [`// ${aliasType} is an alias for ${canonicalType}.`, `type ${aliasType} = ${canonicalType}`].join('\n'),
|
|
57
|
+
inScope: isEnumInScope(enumDef.name, ctx),
|
|
58
|
+
});
|
|
39
59
|
continue;
|
|
40
60
|
}
|
|
41
61
|
|
|
@@ -43,9 +63,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
43
63
|
|
|
44
64
|
if (enumDef.values.length === 0) {
|
|
45
65
|
const humanized = humanize(enumDef.name);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
enumBlocks.push({
|
|
67
|
+
names: [typeName],
|
|
68
|
+
text: [`// ${typeName} represents ${humanized} values.`, `type ${typeName} = string`].join('\n'),
|
|
69
|
+
inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
|
|
70
|
+
});
|
|
49
71
|
continue;
|
|
50
72
|
}
|
|
51
73
|
|
|
@@ -61,10 +83,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
const humanized = humanize(enumDef.name);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
const blockLines: string[] = [];
|
|
87
|
+
blockLines.push(`// ${typeName} represents ${humanized} values.`);
|
|
88
|
+
blockLines.push(`type ${typeName} string`);
|
|
89
|
+
blockLines.push('');
|
|
90
|
+
blockLines.push('const (');
|
|
68
91
|
|
|
69
92
|
const usedNames = new Set<string>();
|
|
70
93
|
for (const v of uniqueValues) {
|
|
@@ -79,15 +102,25 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
79
102
|
usedNames.add(constName);
|
|
80
103
|
const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
|
|
81
104
|
if (v.description) {
|
|
82
|
-
|
|
105
|
+
blockLines.push(`\t// ${constName} is ${v.description}.`);
|
|
83
106
|
}
|
|
84
107
|
if (v.deprecated) {
|
|
85
|
-
if (v.description)
|
|
86
|
-
|
|
108
|
+
if (v.description) blockLines.push(`\t//`);
|
|
109
|
+
blockLines.push(`\t// Deprecated: this value is deprecated.`);
|
|
87
110
|
}
|
|
88
|
-
|
|
111
|
+
blockLines.push(`\t${constName} ${typeName} = ${valueStr}`);
|
|
89
112
|
}
|
|
90
|
-
|
|
113
|
+
blockLines.push(')');
|
|
114
|
+
enumBlocks.push({
|
|
115
|
+
names: [typeName],
|
|
116
|
+
text: blockLines.join('\n'),
|
|
117
|
+
inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const reconciled = reconcileFlatBlocks(enumBlocks, 'enums.go', ctx);
|
|
122
|
+
for (const text of reconciled) {
|
|
123
|
+
lines.push(text);
|
|
91
124
|
lines.push('');
|
|
92
125
|
}
|
|
93
126
|
|
|
@@ -96,7 +129,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
96
129
|
content: lines.join('\n'),
|
|
97
130
|
overwriteExisting: true,
|
|
98
131
|
});
|
|
99
|
-
const eventConstantsFile = generateEventConstantsFile(enums);
|
|
132
|
+
const eventConstantsFile = generateEventConstantsFile(enums, ctx);
|
|
100
133
|
if (eventConstantsFile) files.push(eventConstantsFile);
|
|
101
134
|
|
|
102
135
|
return files;
|
|
@@ -175,10 +208,28 @@ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
|
|
|
175
208
|
return aliasOf;
|
|
176
209
|
}
|
|
177
210
|
|
|
178
|
-
|
|
211
|
+
const EVENTS_FILE_PATH = 'pkg/events/events.go';
|
|
212
|
+
|
|
213
|
+
function generateEventConstantsFile(enums: Enum[], ctx: EmitterContext): GeneratedFile | null {
|
|
179
214
|
const enumDef = findWebhookEventEnum(enums);
|
|
180
215
|
if (!enumDef) return null;
|
|
181
216
|
|
|
217
|
+
// Scoped-run gate for the flat events file. Unlike models/enums, an event
|
|
218
|
+
// value can't be mapped to a single selected service, so there is no
|
|
219
|
+
// per-event "in scope" signal. The correct Option-B behavior is therefore to
|
|
220
|
+
// keep this aggregate byte-stable in a scoped run: emit only event constants
|
|
221
|
+
// that already existed in the prior events.go (dropping brand-new additions
|
|
222
|
+
// such as `session.reauthenticated` from an out-of-scope service) and never
|
|
223
|
+
// drop a constant that was present before. Equivalent to: in a scoped run,
|
|
224
|
+
// emit exactly the prior file's constant set.
|
|
225
|
+
let priorEventConsts: Set<string> | null = null;
|
|
226
|
+
if (isScopedRun(ctx)) {
|
|
227
|
+
const prior = readPriorFile(EVENTS_FILE_PATH, ctx);
|
|
228
|
+
if (prior !== null) {
|
|
229
|
+
priorEventConsts = collectPriorEventConstNames(prior);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
182
233
|
const lines: string[] = [];
|
|
183
234
|
lines.push('package events');
|
|
184
235
|
lines.push('');
|
|
@@ -195,6 +246,13 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
|
|
|
195
246
|
seenValues.add(valueStr);
|
|
196
247
|
|
|
197
248
|
const constName = uniqueEventConstantName(valueStr, usedNames);
|
|
249
|
+
|
|
250
|
+
// Scoped run: skip a constant that wasn't in the prior file (a brand-new,
|
|
251
|
+
// out-of-scope event). When there's no prior file to compare against, fall
|
|
252
|
+
// through and emit everything (first generation / non-target run).
|
|
253
|
+
if (priorEventConsts !== null && !priorEventConsts.has(constName)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
198
256
|
usedNames.add(constName);
|
|
199
257
|
|
|
200
258
|
if (value.description) {
|
|
@@ -213,12 +271,27 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
|
|
|
213
271
|
lines.push('');
|
|
214
272
|
|
|
215
273
|
return {
|
|
216
|
-
path:
|
|
274
|
+
path: EVENTS_FILE_PATH,
|
|
217
275
|
content: lines.join('\n'),
|
|
218
276
|
overwriteExisting: true,
|
|
219
277
|
};
|
|
220
278
|
}
|
|
221
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Collect the constant names declared in a prior `pkg/events/events.go`. Each
|
|
282
|
+
* event constant is emitted as `\tConstName = "wire.value"` inside the single
|
|
283
|
+
* `const (...)` block, so a simple line scan recovers the prior name set used
|
|
284
|
+
* to keep the file byte-stable across scoped runs.
|
|
285
|
+
*/
|
|
286
|
+
function collectPriorEventConstNames(content: string): Set<string> {
|
|
287
|
+
const names = new Set<string>();
|
|
288
|
+
for (const raw of content.split('\n')) {
|
|
289
|
+
const m = raw.trim().match(/^(\w+)\s*=\s*"/);
|
|
290
|
+
if (m) names.add(m[1]);
|
|
291
|
+
}
|
|
292
|
+
return names;
|
|
293
|
+
}
|
|
294
|
+
|
|
222
295
|
function findWebhookEventEnum(enums: Enum[]): Enum | null {
|
|
223
296
|
return (
|
|
224
297
|
enums.find((enumDef) => enumDef.name === 'CreateWebhookEndpointEvents') ??
|
package/src/go/fixtures.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
1
|
+
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { fileName, domainFieldName } from './naming.js';
|
|
3
3
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
4
4
|
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
5
|
+
import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Prefix mapping for generating realistic ID fixture values.
|
|
@@ -24,8 +25,18 @@ export const ID_PREFIXES: Record<string, string> = {
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Generate JSON fixture files for test data.
|
|
28
|
+
*
|
|
29
|
+
* Scoped runs only emit a fixture for a model whose per-model fixture FILE will
|
|
30
|
+
* exist on disk after the run (in-scope, or already present from a prior run —
|
|
31
|
+
* the fixture path is per-model so the prior manifest records it directly).
|
|
32
|
+
* This drops brand-new out-of-scope fixtures (the round-trip ADDITION case) and
|
|
33
|
+
* keeps prior fixtures untouched, mirroring the Rust fixtures fix. `ctx` is
|
|
34
|
+
* optional so unit tests that assert raw content can call it for a full run.
|
|
27
35
|
*/
|
|
28
|
-
export function generateFixtures(
|
|
36
|
+
export function generateFixtures(
|
|
37
|
+
spec: { models: Model[]; enums: Enum[]; services: any[] },
|
|
38
|
+
ctx?: EmitterContext,
|
|
39
|
+
): {
|
|
29
40
|
files: { path: string; content: string }[];
|
|
30
41
|
pathRewrites: Map<string, string>;
|
|
31
42
|
} {
|
|
@@ -38,14 +49,21 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
38
49
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
39
50
|
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
40
51
|
|
|
52
|
+
// Full run (no ctx / no scoping): every fixture is in scope.
|
|
53
|
+
const fixtureInScope = (relPath: string, modelName: string): boolean =>
|
|
54
|
+
!ctx || fileExistsAfterRun(relPath, isModelInScope(modelName, ctx), ctx);
|
|
55
|
+
|
|
41
56
|
for (const model of spec.models) {
|
|
42
57
|
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
43
58
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
44
59
|
|
|
60
|
+
const path = `testdata/${fileName(model.name)}.json`;
|
|
61
|
+
if (!fixtureInScope(path, model.name)) continue;
|
|
62
|
+
|
|
45
63
|
const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
|
|
46
64
|
|
|
47
65
|
files.push({
|
|
48
|
-
path
|
|
66
|
+
path,
|
|
49
67
|
content: JSON.stringify(fixture, null, 2),
|
|
50
68
|
});
|
|
51
69
|
}
|
|
@@ -67,6 +85,10 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
|
|
|
67
85
|
const path = `testdata/list_${fileName(itemModel.name)}.json`;
|
|
68
86
|
if (seenListPaths.has(path)) continue;
|
|
69
87
|
seenListPaths.add(path);
|
|
88
|
+
// Scoped run: only emit a list fixture whose file will exist after the
|
|
89
|
+
// run (in-scope item model, or already on disk). Keyed on the item
|
|
90
|
+
// model so an out-of-scope paginated endpoint doesn't add a new file.
|
|
91
|
+
if (!fixtureInScope(path, itemModel.name)) continue;
|
|
70
92
|
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
71
93
|
const listFixture = {
|
|
72
94
|
data: [fixture],
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { EmitterContext } from '@workos/oagen';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { isScopedRun } from '../shared/resolved-ops.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scoped-run reconciliation for Go's FLAT aggregate files.
|
|
8
|
+
*
|
|
9
|
+
* Unlike Rust (one source file per model/enum), Go inlines every type into a
|
|
10
|
+
* single `models.go` / `enums.go` and every webhook event into one
|
|
11
|
+
* `pkg/events/events.go`. A scoped (`--services`) run regenerates these flat
|
|
12
|
+
* files from the FULL new spec, which breaks scoping two ways:
|
|
13
|
+
*
|
|
14
|
+
* 1. ADDITION — a brand-new model/enum/event that belongs to an OUT-OF-SCOPE
|
|
15
|
+
* service gets inlined, so a scoped batch leaks unrelated changes
|
|
16
|
+
* (violates Option B: a scoped batch should contain ONLY the selected
|
|
17
|
+
* service's changes).
|
|
18
|
+
* 2. REMOVAL / RENAME — a type the new spec renamed away (e.g.
|
|
19
|
+
* `OrganizationDomainStandAlone` → `OrganizationDomain`) is no longer
|
|
20
|
+
* produced, so its block vanishes from `models.go`; but an out-of-scope
|
|
21
|
+
* resource file (NOT regenerated this run) still references the old name →
|
|
22
|
+
* `undefined: OrganizationDomainStandAlone` and the package won't compile.
|
|
23
|
+
*
|
|
24
|
+
* The fix mirrors the Rust principle: in a scoped run, only the selected
|
|
25
|
+
* services' types should reflect the new spec; everything else must be
|
|
26
|
+
* preserved exactly as it is on disk. Because Go's manifest records the flat
|
|
27
|
+
* FILE path (`models.go`) and not per-type paths, we recover the per-type
|
|
28
|
+
* "present before" signal by reading the prior file from `ctx.outputDir`
|
|
29
|
+
* (the emitter already reads `go.mod` from there in tests.ts). A type/const
|
|
30
|
+
* block is then reconciled as:
|
|
31
|
+
*
|
|
32
|
+
* - KEEP a freshly generated block iff it is IN-SCOPE or its name was present
|
|
33
|
+
* in the prior file → drops brand-new out-of-scope additions (fix #1).
|
|
34
|
+
* - CARRY OVER verbatim any prior block whose name(s) the new spec no longer
|
|
35
|
+
* produces → retains renamed/removed types (fix #2).
|
|
36
|
+
*
|
|
37
|
+
* A full (non-scoped) run skips all of this and emits the new spec verbatim.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** A single named top-level declaration block in a flat Go file. */
|
|
41
|
+
export interface NamedBlock {
|
|
42
|
+
/** Every type/const name this block declares (a batched `type (...)` alias block declares several). */
|
|
43
|
+
names: string[];
|
|
44
|
+
/** Verbatim text of the block (no trailing blank line). */
|
|
45
|
+
text: string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the block's owning model/enum is in scope this run. Set by the
|
|
48
|
+
* generator for freshly produced blocks; omitted (treated as false) for
|
|
49
|
+
* blocks parsed from the prior on-disk file.
|
|
50
|
+
*/
|
|
51
|
+
inScope?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read the prior on-disk content of a generated file, or null when unavailable. */
|
|
55
|
+
export function readPriorFile(relPath: string, ctx: EmitterContext): string | null {
|
|
56
|
+
if (!ctx.outputDir) return null;
|
|
57
|
+
const abs = resolve(ctx.outputDir, relPath);
|
|
58
|
+
if (!existsSync(abs)) return null;
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync(abs, 'utf-8');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a flat Go file into the set of top-level type names it declares, mapped
|
|
68
|
+
* to the verbatim text of each declaration block (including its leading doc
|
|
69
|
+
* comment). Used to recover the per-type "present before" signal a scoped run
|
|
70
|
+
* needs. Recognizes the exact shapes the Go emitter produces:
|
|
71
|
+
* - `type Name struct { ... }` (brace-balanced)
|
|
72
|
+
* - `type Name = Other` (single-line alias)
|
|
73
|
+
* - `type Name string` + `const ( ... )` (string enum)
|
|
74
|
+
* - `type ( A = X\n B = X )` (batched alias block — declares many)
|
|
75
|
+
* The leading `package` clause and any standalone trailing `const (...)` block
|
|
76
|
+
* (e.g. the events file) are returned separately by {@link parseFlatGoBlocks}.
|
|
77
|
+
*/
|
|
78
|
+
export function parseFlatGoBlocks(content: string): {
|
|
79
|
+
blocks: NamedBlock[];
|
|
80
|
+
byName: Map<string, NamedBlock>;
|
|
81
|
+
} {
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
const blocks: NamedBlock[] = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
// Skip the generated header / package clause / leading blanks; those are
|
|
87
|
+
// re-emitted by the generator, not carried over.
|
|
88
|
+
while (i < lines.length) {
|
|
89
|
+
const t = lines[i].trim();
|
|
90
|
+
if (t.startsWith('package ') || t === '' || t.startsWith('// Code generated')) {
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
while (i < lines.length) {
|
|
98
|
+
// Collect a leading run of `//` doc-comment lines.
|
|
99
|
+
const start = i;
|
|
100
|
+
while (i < lines.length && lines[i].trim().startsWith('//')) i++;
|
|
101
|
+
|
|
102
|
+
if (i >= lines.length) break;
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
|
|
106
|
+
if (trimmed === 'type (') {
|
|
107
|
+
// Batched alias block: `type (` ... `)`. Each inner line is `Name = Other`.
|
|
108
|
+
const names: string[] = [];
|
|
109
|
+
i++;
|
|
110
|
+
while (i < lines.length && lines[i].trim() !== ')') {
|
|
111
|
+
const m = lines[i].trim().match(/^(\w+)\s*=/);
|
|
112
|
+
if (m) names.push(m[1]);
|
|
113
|
+
i++;
|
|
114
|
+
}
|
|
115
|
+
i++; // consume ')'
|
|
116
|
+
blocks.push({ names, text: lines.slice(start, i).join('\n') });
|
|
117
|
+
} else if (/^type\s+(\w+)\s+struct\s*\{/.test(trimmed)) {
|
|
118
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
119
|
+
// Brace-balanced struct body.
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let sawOpen = false;
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
for (const ch of lines[i]) {
|
|
124
|
+
if (ch === '{') {
|
|
125
|
+
depth++;
|
|
126
|
+
sawOpen = true;
|
|
127
|
+
} else if (ch === '}') depth--;
|
|
128
|
+
}
|
|
129
|
+
i++;
|
|
130
|
+
if (sawOpen && depth === 0) break;
|
|
131
|
+
}
|
|
132
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
133
|
+
} else if (/^type\s+(\w+)\s+\w+\s+string\b/.test(trimmed) || /^type\s+(\w+)\s+string\b/.test(trimmed)) {
|
|
134
|
+
// String enum: `type Name string` possibly followed by a `const ( ... )`.
|
|
135
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
136
|
+
i++;
|
|
137
|
+
// Skip blank lines then an optional const block.
|
|
138
|
+
let j = i;
|
|
139
|
+
while (j < lines.length && lines[j].trim() === '') j++;
|
|
140
|
+
if (j < lines.length && lines[j].trim() === 'const (') {
|
|
141
|
+
i = j;
|
|
142
|
+
while (i < lines.length && lines[i].trim() !== ')') i++;
|
|
143
|
+
i++; // consume ')'
|
|
144
|
+
}
|
|
145
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
146
|
+
} else if (/^type\s+(\w+)\s*=/.test(trimmed)) {
|
|
147
|
+
// Single-line alias: `type Name = Other`.
|
|
148
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
149
|
+
i++;
|
|
150
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
151
|
+
} else {
|
|
152
|
+
// Unrecognized top-level construct (e.g. a standalone `const (...)`).
|
|
153
|
+
// Skip the line so parsing stays robust; such constructs are never
|
|
154
|
+
// carried over by name.
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
// Skip trailing blank lines between blocks (re-added on reassembly).
|
|
158
|
+
while (i < lines.length && lines[i].trim() === '') i++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const byName = new Map<string, NamedBlock>();
|
|
162
|
+
for (const b of blocks) for (const n of b.names) byName.set(n, b);
|
|
163
|
+
return { blocks, byName };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reconcile freshly generated named blocks against the prior on-disk file for a
|
|
168
|
+
* scoped run. Returns the ordered list of block texts to emit. See file-level
|
|
169
|
+
* docs for the keep/carry-over rules. In a scoped run a block is emitted as:
|
|
170
|
+
* - IN-SCOPE → the freshly generated text (apply the new spec).
|
|
171
|
+
* - out-of-scope, existed before → the PRIOR on-disk text, FROZEN, so an
|
|
172
|
+
* unrelated change to that type in the same spec delta doesn't leak into a
|
|
173
|
+
* scoped batch (Option B). (Falls back to fresh text only when the new
|
|
174
|
+
* block's names span multiple prior blocks — a batched-alias regrouping —
|
|
175
|
+
* which can't be frozen 1:1.)
|
|
176
|
+
* - out-of-scope, brand-new → dropped (the addition that broke the build).
|
|
177
|
+
* Then any prior block the new spec no longer produces at all is carried over
|
|
178
|
+
* verbatim (renamed/removed types still referenced by un-regenerated code).
|
|
179
|
+
*
|
|
180
|
+
* @param newBlocks Per-type blocks the current spec produced (in emit order),
|
|
181
|
+
* each tagged with `inScope`.
|
|
182
|
+
* @param relPath Flat file path (e.g. `models.go`) for reading the prior file.
|
|
183
|
+
* @param ctx Emitter context (provides `outputDir` + scope sets).
|
|
184
|
+
* @param alsoEmitted Names this file emits OUTSIDE `newBlocks` (e.g. the fixed
|
|
185
|
+
* `PaginationParams` struct). They must be excluded from the
|
|
186
|
+
* carry-over, or the prior copy would be re-appended and
|
|
187
|
+
* redeclare the separately-emitted one.
|
|
188
|
+
*/
|
|
189
|
+
export function reconcileFlatBlocks(
|
|
190
|
+
newBlocks: NamedBlock[],
|
|
191
|
+
relPath: string,
|
|
192
|
+
ctx: EmitterContext,
|
|
193
|
+
alsoEmitted: Set<string> = new Set<string>(),
|
|
194
|
+
): string[] {
|
|
195
|
+
// Full run: emit everything the new spec produced, unchanged.
|
|
196
|
+
if (!isScopedRun(ctx)) return newBlocks.map((b) => b.text);
|
|
197
|
+
|
|
198
|
+
const prior = readPriorFile(relPath, ctx);
|
|
199
|
+
// No prior file to reconcile against (first generation / missing output dir):
|
|
200
|
+
// fall back to scope-only gating so we never leak brand-new out-of-scope
|
|
201
|
+
// types, but there's nothing on disk to retain.
|
|
202
|
+
const priorParsed = prior ? parseFlatGoBlocks(prior) : { blocks: [], byName: new Map<string, NamedBlock>() };
|
|
203
|
+
|
|
204
|
+
const out: string[] = [];
|
|
205
|
+
const emittedNames = new Set<string>();
|
|
206
|
+
|
|
207
|
+
for (const block of newBlocks) {
|
|
208
|
+
if (block.inScope) {
|
|
209
|
+
out.push(block.text);
|
|
210
|
+
for (const n of block.names) emittedNames.add(n);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Out of scope: freeze to the prior on-disk text when this block maps 1:1
|
|
214
|
+
// to a single prior block; that keeps an out-of-scope type byte-identical to
|
|
215
|
+
// disk even if the new spec changed it. A brand-new out-of-scope block has
|
|
216
|
+
// no prior block → it is dropped.
|
|
217
|
+
const priorBlocks = block.names.map((n) => priorParsed.byName.get(n)).filter((b): b is NamedBlock => !!b);
|
|
218
|
+
const uniquePrior = new Set(priorBlocks);
|
|
219
|
+
if (uniquePrior.size === 1) {
|
|
220
|
+
const pb = priorBlocks[0];
|
|
221
|
+
if (!pb.names.some((n) => emittedNames.has(n))) {
|
|
222
|
+
out.push(pb.text);
|
|
223
|
+
for (const n of pb.names) emittedNames.add(n);
|
|
224
|
+
}
|
|
225
|
+
} else if (uniquePrior.size > 1) {
|
|
226
|
+
// A regrouping spread this out-of-scope block's names across several prior
|
|
227
|
+
// blocks. NEVER regenerate out-of-scope content (the fresh text could
|
|
228
|
+
// re-point an alias at a renamed canonical the scoped run didn't emit);
|
|
229
|
+
// instead freeze every distinct prior block verbatim. All names are
|
|
230
|
+
// on-disk here (the generator only puts in-scope ∪ on-disk names in an
|
|
231
|
+
// out-of-scope block), so this fully retains them.
|
|
232
|
+
for (const pb of uniquePrior) {
|
|
233
|
+
if (pb.names.some((n) => emittedNames.has(n))) continue;
|
|
234
|
+
out.push(pb.text);
|
|
235
|
+
for (const n of pb.names) emittedNames.add(n);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// uniquePrior.size === 0 → brand-new out-of-scope → drop.
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Carry over prior blocks the new spec no longer produces at all (renamed /
|
|
242
|
+
// removed types still referenced by out-of-scope, un-regenerated code),
|
|
243
|
+
// excluding any name this file emits elsewhere (e.g. PaginationParams).
|
|
244
|
+
for (const block of priorParsed.blocks) {
|
|
245
|
+
if (block.names.some((n) => alsoEmitted.has(n))) continue;
|
|
246
|
+
if (block.names.every((n) => !emittedNames.has(n))) {
|
|
247
|
+
out.push(block.text);
|
|
248
|
+
for (const n of block.names) emittedNames.add(n);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return out;
|
|
253
|
+
}
|