@workos/oagen-emitters 0.3.0 → 0.5.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-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/node/models.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
4
|
import { mapTypeRef, mapWireTypeRef } from './type-map.js';
|
|
3
5
|
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
@@ -7,7 +9,6 @@ import {
|
|
|
7
9
|
buildGenericModelDefaults,
|
|
8
10
|
pruneUnusedImports,
|
|
9
11
|
TS_BUILTINS,
|
|
10
|
-
detectStringDateConvention,
|
|
11
12
|
buildKnownTypeNames,
|
|
12
13
|
isBaselineGeneric,
|
|
13
14
|
createServiceDirResolver,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
isListWrapperModel,
|
|
16
17
|
buildDeduplicationMap,
|
|
17
18
|
relativeImport,
|
|
19
|
+
modelHasNewFields,
|
|
20
|
+
computeNonEventReachable,
|
|
18
21
|
} from './utils.js';
|
|
19
22
|
import { assignEnumsToServices } from './enums.js';
|
|
20
23
|
import {
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
buildSkipFormatFields,
|
|
24
27
|
shouldSkipSerializeForModel,
|
|
25
28
|
emitSerializerBody,
|
|
29
|
+
hasDateTimeConversion,
|
|
26
30
|
} from './field-plan.js';
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -52,10 +56,9 @@ function enrichGenericDefaultsFromBaseline(
|
|
|
52
56
|
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
53
57
|
if (!baseline?.fields) continue;
|
|
54
58
|
|
|
55
|
-
// Only enrich generic defaults for models whose baseline file
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// to it don't need type args.
|
|
59
|
+
// Only enrich generic defaults for models whose baseline file path matches
|
|
60
|
+
// the generated path. If the file is generated in a new directory, it
|
|
61
|
+
// won't have generics, so references to it don't need type args.
|
|
59
62
|
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
60
63
|
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
61
64
|
if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
|
|
@@ -72,23 +75,56 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
72
75
|
const {
|
|
73
76
|
modelToService,
|
|
74
77
|
resolveDir,
|
|
75
|
-
useStringDates,
|
|
76
78
|
dedup: sharedDedup,
|
|
77
79
|
genericDefaults: sharedDefaults,
|
|
78
80
|
} = shared ?? buildSharedContext(models, ctx);
|
|
79
81
|
const genericDefaults = sharedDefaults;
|
|
80
|
-
const typeRefOpts =
|
|
82
|
+
const typeRefOpts = { genericDefaults };
|
|
81
83
|
const wireTypeRefOpts = { genericDefaults };
|
|
82
84
|
const files: GeneratedFile[] = [];
|
|
83
85
|
const dedup = sharedDedup;
|
|
84
86
|
|
|
87
|
+
// Only generate files for models reachable from non-event service operations.
|
|
88
|
+
// Event operations (listEvents) pull in hundreds of webhook payload models
|
|
89
|
+
// that the existing SDK handles via hand-written event types. Skip those.
|
|
90
|
+
const reachableModels = computeNonEventReachable(ctx.spec.services, models);
|
|
91
|
+
|
|
92
|
+
// Force-generate models that are dependencies of generated models but whose
|
|
93
|
+
// baseline definitions are inline in another file. The merger will replace the
|
|
94
|
+
// parent symbol, losing the inline definition, so a separate file is needed.
|
|
95
|
+
const forceGenerate = new Set<string>();
|
|
96
|
+
for (const model of models) {
|
|
97
|
+
if (!reachableModels.has(model.name)) continue;
|
|
98
|
+
if (!modelHasNewFields(model, ctx)) continue;
|
|
99
|
+
const service = modelToService.get(model.name);
|
|
100
|
+
const dirName = resolveDir(service);
|
|
101
|
+
const parentPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
102
|
+
const deps = collectFieldDependencies(model);
|
|
103
|
+
for (const dep of deps.models) {
|
|
104
|
+
if (forceGenerate.has(dep)) continue;
|
|
105
|
+
const depName = resolveInterfaceName(dep, ctx);
|
|
106
|
+
const depBaseline = ctx.apiSurface?.interfaces?.[depName];
|
|
107
|
+
const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
|
|
108
|
+
if (depSrc === parentPath) {
|
|
109
|
+
// The dependency's baseline is inline in the parent's file — force-generate
|
|
110
|
+
forceGenerate.add(dep);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
for (const model of models) {
|
|
116
|
+
if (!reachableModels.has(model.name)) continue;
|
|
117
|
+
|
|
86
118
|
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
87
119
|
if (isListMetadataModel(model)) continue;
|
|
88
120
|
|
|
89
121
|
// Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
|
|
90
122
|
if (isListWrapperModel(model)) continue;
|
|
91
123
|
|
|
124
|
+
// Skip models that are unchanged from baseline (no new fields),
|
|
125
|
+
// unless they're force-generated (inline dependency of a regenerated model).
|
|
126
|
+
if (!modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
|
|
127
|
+
|
|
92
128
|
// Deduplication: if this model is structurally identical to a canonical model,
|
|
93
129
|
// emit a type alias instead of a full interface.
|
|
94
130
|
const canonicalName = dedup.get(model.name);
|
|
@@ -97,8 +133,8 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
97
133
|
const dirName = resolveDir(service);
|
|
98
134
|
|
|
99
135
|
// Skip typeAlias resolution for dedup models. The canonical file may
|
|
100
|
-
//
|
|
101
|
-
//
|
|
136
|
+
// still export its raw name, so the import names must match the raw
|
|
137
|
+
// exports, not resolved aliases.
|
|
102
138
|
const skipTA = { skipTypeAlias: true };
|
|
103
139
|
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
104
140
|
const responseName = wireInterfaceName(domainName);
|
|
@@ -128,7 +164,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
128
164
|
files.push({
|
|
129
165
|
path: aliasPath,
|
|
130
166
|
content: aliasLines.join('\n'),
|
|
131
|
-
|
|
167
|
+
overwriteExisting: true,
|
|
132
168
|
});
|
|
133
169
|
continue;
|
|
134
170
|
}
|
|
@@ -211,10 +247,27 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
211
247
|
if (irEnumName && !deps.enums.has(irEnumName)) {
|
|
212
248
|
const eService = enumToService.get(irEnumName);
|
|
213
249
|
const eDir = resolveDir(eService);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
250
|
+
// Check baseline sourceFile — if the enum lives at a different path
|
|
251
|
+
// than the generated one, import from the baseline location.
|
|
252
|
+
const bEnum = ctx.apiSurface?.enums?.[irEnumName];
|
|
253
|
+
const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
|
|
254
|
+
const bSrc = (bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile;
|
|
255
|
+
const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
|
|
256
|
+
const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
257
|
+
// If defined inline in the same file, just add to importable names
|
|
258
|
+
if (bSrc === cPath) {
|
|
259
|
+
importableNames.add(name);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
let relPath: string;
|
|
263
|
+
if (bSrc && bSrc !== gPath) {
|
|
264
|
+
relPath = relativeImport(cPath, bSrc).replace(/\.ts$/, '');
|
|
265
|
+
} else {
|
|
266
|
+
relPath =
|
|
267
|
+
eDir === dirName
|
|
268
|
+
? `./${fileName(irEnumName)}.interface`
|
|
269
|
+
: `../../${eDir}/interfaces/${fileName(irEnumName)}.interface`;
|
|
270
|
+
}
|
|
218
271
|
crossServiceImports.set(name, { name, relPath });
|
|
219
272
|
importableNames.add(name);
|
|
220
273
|
continue;
|
|
@@ -246,10 +299,32 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
246
299
|
lines.push(`import type { ${depName}, ${wireInterfaceName(depName)} } from '${relPath}';`);
|
|
247
300
|
}
|
|
248
301
|
for (const dep of deps.enums) {
|
|
302
|
+
// Check if the enum has a baseline sourceFile — if it lives at a
|
|
303
|
+
// different path than the generated one, import from the baseline
|
|
304
|
+
// location since the enum file won't be regenerated there.
|
|
305
|
+
const baselineEnum = ctx.apiSurface?.enums?.[dep];
|
|
306
|
+
const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
|
|
307
|
+
const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
|
|
249
308
|
const depService = enumToService.get(dep);
|
|
250
309
|
const depDir = resolveDir(depService);
|
|
251
|
-
const
|
|
252
|
-
|
|
310
|
+
const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
|
|
311
|
+
const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
312
|
+
|
|
313
|
+
// If the baseline enum is defined in the SAME file we're generating,
|
|
314
|
+
// skip the import — the merger will preserve the inline definition.
|
|
315
|
+
if (baselineSrc === currentFilePath) {
|
|
316
|
+
importableNames.add(dep);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let relPath: string;
|
|
321
|
+
if (baselineSrc && baselineSrc !== generatedPath) {
|
|
322
|
+
// Baseline provides the enum from a different file — import from there.
|
|
323
|
+
relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
|
|
324
|
+
} else {
|
|
325
|
+
relPath =
|
|
326
|
+
depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
|
|
327
|
+
}
|
|
253
328
|
lines.push(`import type { ${dep} } from '${relPath}';`);
|
|
254
329
|
}
|
|
255
330
|
for (const [, imp] of crossServiceImports) {
|
|
@@ -303,6 +378,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
303
378
|
if (
|
|
304
379
|
baselineField &&
|
|
305
380
|
!domainResponseOptionalMismatch &&
|
|
381
|
+
!hasDateTimeConversion(field.type) &&
|
|
306
382
|
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
307
383
|
baselineFieldCompatible(baselineField, field)
|
|
308
384
|
) {
|
|
@@ -366,10 +442,100 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
366
442
|
lines.push('}');
|
|
367
443
|
} // close else for non-empty wire interface
|
|
368
444
|
|
|
445
|
+
// When overwriting an existing interface file, preserve inline types whose
|
|
446
|
+
// sourceFile matches this file but which aren't generated as separate files.
|
|
447
|
+
// Query the apiSurface rather than parsing the target file with regex.
|
|
448
|
+
const filePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
449
|
+
if (ctx.apiSurface) {
|
|
450
|
+
const generatedNames = new Set<string>();
|
|
451
|
+
for (const line of lines) {
|
|
452
|
+
const m = line.match(/^export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/);
|
|
453
|
+
if (m) generatedNames.add(m[1]);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Read the existing file to extract inline declarations verbatim
|
|
457
|
+
if (ctx.targetDir) {
|
|
458
|
+
try {
|
|
459
|
+
const existingContent = fs.readFileSync(path.join(ctx.targetDir, filePath), 'utf-8');
|
|
460
|
+
// Collect names of inline types from the apiSurface
|
|
461
|
+
const inlineNames = new Set<string>();
|
|
462
|
+
const checkSurface = (items: Record<string, any> | undefined) => {
|
|
463
|
+
if (!items) return;
|
|
464
|
+
for (const [name, item] of Object.entries(items)) {
|
|
465
|
+
const src = (item as any).sourceFile as string | undefined;
|
|
466
|
+
if (src !== filePath) continue;
|
|
467
|
+
if (generatedNames.has(name)) continue;
|
|
468
|
+
// Check that no separate file is generated for this type
|
|
469
|
+
const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
|
|
470
|
+
if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
|
|
471
|
+
inlineNames.add(name);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
checkSurface(ctx.apiSurface.interfaces);
|
|
475
|
+
checkSurface(ctx.apiSurface.typeAliases);
|
|
476
|
+
checkSurface(ctx.apiSurface.enums);
|
|
477
|
+
|
|
478
|
+
// Extract each inline type's verbatim declaration from the existing file
|
|
479
|
+
if (inlineNames.size > 0) {
|
|
480
|
+
const existingLines = existingContent.split('\n');
|
|
481
|
+
let ei = 0;
|
|
482
|
+
while (ei < existingLines.length) {
|
|
483
|
+
const eline = existingLines[ei];
|
|
484
|
+
// Match exported or non-exported declarations
|
|
485
|
+
const dm = eline.match(/^(export\s+)?(?:interface|type|enum|class|const|function)\s+(\w+)/);
|
|
486
|
+
if (!dm || !inlineNames.has(dm[2])) {
|
|
487
|
+
ei++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Collect the full declaration via brace tracking
|
|
492
|
+
const block: string[] = [eline];
|
|
493
|
+
let braces = (eline.match(/\{/g) || []).length - (eline.match(/\}/g) || []).length;
|
|
494
|
+
if (braces === 0 && eline.includes(';')) {
|
|
495
|
+
lines.push('');
|
|
496
|
+
lines.push(block.join('\n'));
|
|
497
|
+
ei++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
// Multi-line type alias (union with |)
|
|
501
|
+
if (braces === 0) {
|
|
502
|
+
ei++;
|
|
503
|
+
while (ei < existingLines.length) {
|
|
504
|
+
const nl = existingLines[ei];
|
|
505
|
+
block.push(nl);
|
|
506
|
+
ei++;
|
|
507
|
+
if (
|
|
508
|
+
nl.trimEnd().endsWith(';') ||
|
|
509
|
+
(nl.trim() !== '' && !nl.trim().startsWith('|') && !nl.trim().startsWith('&'))
|
|
510
|
+
)
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
lines.push('');
|
|
514
|
+
lines.push(block.join('\n'));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Brace-delimited
|
|
518
|
+
ei++;
|
|
519
|
+
while (ei < existingLines.length && braces > 0) {
|
|
520
|
+
const nl = existingLines[ei];
|
|
521
|
+
block.push(nl);
|
|
522
|
+
braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
|
|
523
|
+
ei++;
|
|
524
|
+
}
|
|
525
|
+
lines.push('');
|
|
526
|
+
lines.push(block.join('\n'));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
// No existing file — nothing to preserve
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
369
535
|
files.push({
|
|
370
|
-
path:
|
|
536
|
+
path: filePath,
|
|
371
537
|
content: pruneUnusedImports(lines).join('\n'),
|
|
372
|
-
|
|
538
|
+
overwriteExisting: true,
|
|
373
539
|
});
|
|
374
540
|
}
|
|
375
541
|
|
|
@@ -477,18 +643,17 @@ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>):
|
|
|
477
643
|
interface SharedModelContext {
|
|
478
644
|
modelToService: Map<string, string>;
|
|
479
645
|
resolveDir: (irService: string | undefined) => string;
|
|
480
|
-
useStringDates: boolean;
|
|
481
646
|
dedup: Map<string, string>;
|
|
482
647
|
genericDefaults: Map<string, string>;
|
|
483
648
|
}
|
|
484
649
|
|
|
485
650
|
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
486
651
|
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
487
|
-
const useStringDates = detectStringDateConvention(models, ctx);
|
|
488
652
|
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
489
653
|
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
490
|
-
const
|
|
491
|
-
|
|
654
|
+
const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
655
|
+
const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
|
|
656
|
+
return { modelToService, resolveDir, dedup, genericDefaults };
|
|
492
657
|
}
|
|
493
658
|
|
|
494
659
|
// ---------------------------------------------------------------------------
|
|
@@ -507,14 +672,94 @@ export function generateSerializers(
|
|
|
507
672
|
): GeneratedFile[] {
|
|
508
673
|
if (models.length === 0) return [];
|
|
509
674
|
|
|
510
|
-
const { modelToService, resolveDir,
|
|
675
|
+
const { modelToService, resolveDir, dedup } = shared ?? buildSharedContext(models, ctx);
|
|
511
676
|
const files: GeneratedFile[] = [];
|
|
512
677
|
const skippedSerializeModels = new Set<string>();
|
|
513
678
|
|
|
679
|
+
// Reuse the same reachability set from generateModels to skip serializers
|
|
680
|
+
// for unreachable models (e.g., event/webhook payload types).
|
|
681
|
+
const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
682
|
+
|
|
683
|
+
// Pre-populate skippedSerializeModels for baseline models that won't be
|
|
684
|
+
// regenerated. Their existing serializers may only export deserialize.
|
|
685
|
+
if (ctx.targetDir) {
|
|
686
|
+
for (const model of models) {
|
|
687
|
+
if (!serializerReachable.has(model.name)) continue;
|
|
688
|
+
if (modelHasNewFields(model, ctx)) continue; // will be regenerated
|
|
689
|
+
const service = modelToService.get(model.name);
|
|
690
|
+
const dirName = resolveDir(service);
|
|
691
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
692
|
+
const serializerFile = path.join(
|
|
693
|
+
ctx.targetDir,
|
|
694
|
+
'src',
|
|
695
|
+
dirName,
|
|
696
|
+
'serializers',
|
|
697
|
+
`${fileName(model.name)}.serializer.ts`,
|
|
698
|
+
);
|
|
699
|
+
try {
|
|
700
|
+
const content = fs.readFileSync(serializerFile, 'utf-8');
|
|
701
|
+
if (!new RegExp(`\\bserialize${domainName}\\b`).test(content)) {
|
|
702
|
+
skippedSerializeModels.add(model.name);
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
// Serializer doesn't exist — model may be new or generated differently
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Force-generate serializers for inline dependency models (same logic as interfaces).
|
|
711
|
+
const forceGenerateSerializer = new Set<string>();
|
|
712
|
+
for (const model of models) {
|
|
713
|
+
if (!serializerReachable.has(model.name)) continue;
|
|
714
|
+
if (!modelHasNewFields(model, ctx)) continue;
|
|
715
|
+
const service = modelToService.get(model.name);
|
|
716
|
+
const dirName = resolveDir(service);
|
|
717
|
+
const parentPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
718
|
+
const deps = collectFieldDependencies(model);
|
|
719
|
+
for (const dep of deps.models) {
|
|
720
|
+
const depName = resolveInterfaceName(dep, ctx);
|
|
721
|
+
const depBaseline = ctx.apiSurface?.interfaces?.[depName];
|
|
722
|
+
const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
|
|
723
|
+
if (depSrc === parentPath) {
|
|
724
|
+
forceGenerateSerializer.add(dep);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- Pass 1: pre-compute which models skip serialize (ordering-independent) ---
|
|
730
|
+
// This must run BEFORE file generation so that buildSerializerImports can
|
|
731
|
+
// check the fully-populated set and avoid importing non-existent serialize functions.
|
|
732
|
+
const eligibleModels: Model[] = [];
|
|
514
733
|
for (const model of models) {
|
|
734
|
+
if (!serializerReachable.has(model.name)) continue;
|
|
515
735
|
if (isListMetadataModel(model)) continue;
|
|
516
736
|
if (isListWrapperModel(model)) continue;
|
|
737
|
+
if (!modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
|
|
738
|
+
eligibleModels.push(model);
|
|
739
|
+
}
|
|
517
740
|
|
|
741
|
+
// First pass: determine shouldSkipSerialize for every eligible model
|
|
742
|
+
for (const model of eligibleModels) {
|
|
743
|
+
if (dedup.has(model.name)) continue; // dedup aliases don't get their own serialize
|
|
744
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
745
|
+
const responseName = wireInterfaceName(domainName);
|
|
746
|
+
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
747
|
+
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
748
|
+
const shouldSkip = shouldSkipSerializeForModel(
|
|
749
|
+
model,
|
|
750
|
+
baselineResponse,
|
|
751
|
+
baselineDomain,
|
|
752
|
+
dedup,
|
|
753
|
+
skippedSerializeModels,
|
|
754
|
+
ctx,
|
|
755
|
+
);
|
|
756
|
+
if (shouldSkip) {
|
|
757
|
+
skippedSerializeModels.add(model.name);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// --- Pass 2: generate serializer files using fully-populated skip set ---
|
|
762
|
+
for (const model of eligibleModels) {
|
|
518
763
|
// Deduplication: for structurally identical models, re-export the canonical serializer
|
|
519
764
|
const canonicalName = dedup.get(model.name);
|
|
520
765
|
if (canonicalName) {
|
|
@@ -536,9 +781,13 @@ export function generateSerializers(
|
|
|
536
781
|
if (serializerPath === canonSerializerPath) continue;
|
|
537
782
|
if (domainName === canonDomainName) continue;
|
|
538
783
|
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
784
|
+
const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
|
|
785
|
+
const reexportContent = canonSkipSerialize
|
|
786
|
+
? `export { deserialize${canonDomainName} as deserialize${domainName} } from '${rel}';`
|
|
787
|
+
: `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`;
|
|
539
788
|
files.push({
|
|
540
789
|
path: serializerPath,
|
|
541
|
-
content:
|
|
790
|
+
content: reexportContent,
|
|
542
791
|
overwriteExisting: true,
|
|
543
792
|
});
|
|
544
793
|
continue;
|
|
@@ -554,20 +803,10 @@ export function generateSerializers(
|
|
|
554
803
|
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
555
804
|
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
556
805
|
|
|
557
|
-
const skipFormatFields = buildSkipFormatFields(model,
|
|
558
|
-
const shouldSkipSerialize =
|
|
559
|
-
model,
|
|
560
|
-
baselineResponse,
|
|
561
|
-
baselineDomain,
|
|
562
|
-
dedup,
|
|
563
|
-
skippedSerializeModels,
|
|
564
|
-
ctx,
|
|
565
|
-
);
|
|
566
|
-
if (shouldSkipSerialize) {
|
|
567
|
-
skippedSerializeModels.add(model.name);
|
|
568
|
-
}
|
|
806
|
+
const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
|
|
807
|
+
const shouldSkipSerialize = skippedSerializeModels.has(model.name);
|
|
569
808
|
|
|
570
|
-
const sctx = { modelToService, resolveDir,
|
|
809
|
+
const sctx = { modelToService, resolveDir, dedup, skippedSerializeModels, ctx };
|
|
571
810
|
const lines = [
|
|
572
811
|
...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
|
|
573
812
|
...emitSerializerBody(
|
|
@@ -586,9 +825,14 @@ export function generateSerializers(
|
|
|
586
825
|
files.push({
|
|
587
826
|
path: serializerPath,
|
|
588
827
|
content: pruneUnusedImports(lines).join('\n'),
|
|
828
|
+
overwriteExisting: true,
|
|
589
829
|
});
|
|
590
830
|
}
|
|
591
831
|
|
|
832
|
+
// Stash the fully-computed skip set on the context so the test generator
|
|
833
|
+
// can read it without duplicating the detection logic.
|
|
834
|
+
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
|
835
|
+
|
|
592
836
|
return files;
|
|
593
837
|
}
|
|
594
838
|
|