@workos/oagen-emitters 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.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/.node-version +1 -1
- 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-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/node/models.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import type { Model, Field, TypeRef, EmitterContext, GeneratedFile, Operation, Service } from '@workos/oagen';
|
|
4
|
+
import { planOperation } from '@workos/oagen';
|
|
5
|
+
import { mapTypeRef, mapWireTypeRef, isInlineEnum } from './type-map.js';
|
|
6
|
+
import {
|
|
7
|
+
fieldName,
|
|
8
|
+
wireFieldName,
|
|
9
|
+
fileName,
|
|
10
|
+
resolveInterfaceName,
|
|
11
|
+
wireInterfaceName,
|
|
12
|
+
resolveMethodName,
|
|
13
|
+
} from './naming.js';
|
|
6
14
|
import {
|
|
7
15
|
collectFieldDependencies,
|
|
8
16
|
docComment,
|
|
@@ -18,6 +26,8 @@ import {
|
|
|
18
26
|
relativeImport,
|
|
19
27
|
modelHasNewFields,
|
|
20
28
|
computeNonEventReachable,
|
|
29
|
+
isServiceCoveredByExisting,
|
|
30
|
+
hasMethodsAbsentFromBaseline,
|
|
21
31
|
} from './utils.js';
|
|
22
32
|
import { assignEnumsToServices } from './enums.js';
|
|
23
33
|
import {
|
|
@@ -28,18 +38,39 @@ import {
|
|
|
28
38
|
emitSerializerBody,
|
|
29
39
|
hasDateTimeConversion,
|
|
30
40
|
} from './field-plan.js';
|
|
41
|
+
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile } from './live-surface.js';
|
|
42
|
+
import { isNodeOwnedService } from './options.js';
|
|
43
|
+
import { unwrapListModel } from './fixtures.js';
|
|
44
|
+
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
45
|
+
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
46
|
+
import { collectWrapperResponseModels } from './wrappers.js';
|
|
47
|
+
import { resolveResourceClassName } from './resources.js';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Shared context
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
interface SharedModelContext {
|
|
54
|
+
modelToService: Map<string, string>;
|
|
55
|
+
resolveDir: (irService: string | undefined) => string;
|
|
56
|
+
dedup: Map<string, string>;
|
|
57
|
+
genericDefaults: Map<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface GeneratedResourceModelUsage {
|
|
61
|
+
interfaceRoots: Set<string>;
|
|
62
|
+
serializerRoots: Set<string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
66
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
67
|
+
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
68
|
+
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
69
|
+
const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
70
|
+
const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
|
|
71
|
+
return { modelToService, resolveDir, dedup, genericDefaults };
|
|
72
|
+
}
|
|
31
73
|
|
|
32
|
-
/**
|
|
33
|
-
* Detect baseline interfaces that are generic (have type parameters) even though
|
|
34
|
-
* the IR model has no typeParams (OpenAPI doesn't support generics).
|
|
35
|
-
*
|
|
36
|
-
* Heuristic: if any field type in the baseline interface contains a PascalCase
|
|
37
|
-
* name that isn't a known model, enum, or builtin, it's likely a type parameter
|
|
38
|
-
* (e.g., `CustomAttributesType`), indicating the interface is generic.
|
|
39
|
-
*
|
|
40
|
-
* When detected, adds a default generic type arg so references like `Profile`
|
|
41
|
-
* become `Profile<Record<string, unknown>>`.
|
|
42
|
-
*/
|
|
43
74
|
function enrichGenericDefaultsFromBaseline(
|
|
44
75
|
genericDefaults: Map<string, string>,
|
|
45
76
|
models: Model[],
|
|
@@ -51,14 +82,11 @@ function enrichGenericDefaultsFromBaseline(
|
|
|
51
82
|
const knownNames = buildKnownTypeNames(models, ctx);
|
|
52
83
|
|
|
53
84
|
for (const model of models) {
|
|
54
|
-
if (genericDefaults.has(model.name)) continue;
|
|
85
|
+
if (genericDefaults.has(model.name)) continue;
|
|
55
86
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
56
87
|
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
57
88
|
if (!baseline?.fields) continue;
|
|
58
89
|
|
|
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.
|
|
62
90
|
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
63
91
|
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
64
92
|
if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
|
|
@@ -69,6 +97,53 @@ function enrichGenericDefaultsFromBaseline(
|
|
|
69
97
|
}
|
|
70
98
|
}
|
|
71
99
|
|
|
100
|
+
function projectModelToManagedSurface(model: Model, shared: SharedModelContext, ctx: EmitterContext): Model {
|
|
101
|
+
if (!ctx.outputDir && !ctx.targetDir) return model;
|
|
102
|
+
if (!liveSurfaceHasExistingSdk()) return model;
|
|
103
|
+
const fields = model.fields.filter((field) => isSupportedFieldType(field.type, model.name, shared, ctx));
|
|
104
|
+
return fields.length === model.fields.length ? model : { ...model, fields };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isSupportedFieldType(
|
|
108
|
+
ref: TypeRef,
|
|
109
|
+
ownerModelName: string,
|
|
110
|
+
shared: SharedModelContext,
|
|
111
|
+
ctx: EmitterContext,
|
|
112
|
+
): boolean {
|
|
113
|
+
switch (ref.kind) {
|
|
114
|
+
case 'primitive':
|
|
115
|
+
case 'literal':
|
|
116
|
+
case 'map':
|
|
117
|
+
return true;
|
|
118
|
+
case 'model': {
|
|
119
|
+
if (ref.name === ownerModelName) return true;
|
|
120
|
+
const resolvedName = resolveInterfaceName(ref.name, ctx);
|
|
121
|
+
if (ctx.apiSurface?.interfaces?.[resolvedName] || ctx.apiSurface?.typeAliases?.[resolvedName]) return true;
|
|
122
|
+
const relPath = `src/${shared.resolveDir(shared.modelToService.get(ref.name))}/interfaces/${fileName(ref.name)}.interface.ts`;
|
|
123
|
+
return liveSurfaceHasManagedFile(relPath);
|
|
124
|
+
}
|
|
125
|
+
case 'enum': {
|
|
126
|
+
if (ctx.apiSurface?.enums?.[ref.name] || ctx.apiSurface?.typeAliases?.[ref.name]) return true;
|
|
127
|
+
const enumService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx).get(ref.name);
|
|
128
|
+
if (enumService) return true;
|
|
129
|
+
const relPath = `src/${shared.resolveDir(enumService)}/interfaces/${fileName(ref.name)}.interface.ts`;
|
|
130
|
+
return liveSurfaceHasManagedFile(relPath);
|
|
131
|
+
}
|
|
132
|
+
case 'array':
|
|
133
|
+
return isSupportedFieldType(ref.items, ownerModelName, shared, ctx);
|
|
134
|
+
case 'nullable':
|
|
135
|
+
return isSupportedFieldType(ref.inner, ownerModelName, shared, ctx);
|
|
136
|
+
case 'union':
|
|
137
|
+
return ref.variants.every((variant) => isSupportedFieldType(variant, ownerModelName, shared, ctx));
|
|
138
|
+
default:
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Interface generation
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
72
147
|
export function generateModels(models: Model[], ctx: EmitterContext, shared?: SharedModelContext): GeneratedFile[] {
|
|
73
148
|
if (models.length === 0) return [];
|
|
74
149
|
|
|
@@ -83,18 +158,22 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
83
158
|
const wireTypeRefOpts = { genericDefaults };
|
|
84
159
|
const files: GeneratedFile[] = [];
|
|
85
160
|
const dedup = sharedDedup;
|
|
161
|
+
const projectedModels = models.map((model) =>
|
|
162
|
+
projectModelToManagedSurface(model, { modelToService, resolveDir, dedup, genericDefaults }, ctx),
|
|
163
|
+
);
|
|
164
|
+
const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
|
|
165
|
+
const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
|
|
166
|
+
const interfaceEligibleModels = resourceUsage
|
|
167
|
+
? expandModelRoots(resourceUsage.interfaceRoots, projectedByName)
|
|
168
|
+
: undefined;
|
|
86
169
|
|
|
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
170
|
const reachableModels = computeNonEventReachable(ctx.spec.services, models);
|
|
91
171
|
|
|
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
172
|
const forceGenerate = new Set<string>();
|
|
96
|
-
for (const
|
|
173
|
+
for (const originalModel of models) {
|
|
174
|
+
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
97
175
|
if (!reachableModels.has(model.name)) continue;
|
|
176
|
+
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
98
177
|
if (!modelHasNewFields(model, ctx)) continue;
|
|
99
178
|
const service = modelToService.get(model.name);
|
|
100
179
|
const dirName = resolveDir(service);
|
|
@@ -106,35 +185,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
106
185
|
const depBaseline = ctx.apiSurface?.interfaces?.[depName];
|
|
107
186
|
const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
|
|
108
187
|
if (depSrc === parentPath) {
|
|
109
|
-
// The dependency's baseline is inline in the parent's file — force-generate
|
|
110
188
|
forceGenerate.add(dep);
|
|
111
189
|
}
|
|
112
190
|
}
|
|
113
191
|
}
|
|
114
192
|
|
|
115
|
-
for (const
|
|
193
|
+
for (const originalModel of models) {
|
|
194
|
+
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
116
195
|
if (!reachableModels.has(model.name)) continue;
|
|
117
|
-
|
|
118
|
-
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
196
|
+
if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
|
|
119
197
|
if (isListMetadataModel(model)) continue;
|
|
120
|
-
|
|
121
|
-
// Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
|
|
122
198
|
if (isListWrapperModel(model)) continue;
|
|
199
|
+
const service = modelToService.get(model.name);
|
|
200
|
+
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
201
|
+
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
|
|
123
202
|
|
|
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
|
-
|
|
128
|
-
// Deduplication: if this model is structurally identical to a canonical model,
|
|
129
|
-
// emit a type alias instead of a full interface.
|
|
130
203
|
const canonicalName = dedup.get(model.name);
|
|
131
|
-
if (canonicalName) {
|
|
132
|
-
const service = modelToService.get(model.name);
|
|
204
|
+
if (canonicalName && !isOwnedModel) {
|
|
133
205
|
const dirName = resolveDir(service);
|
|
134
|
-
|
|
135
|
-
// Skip typeAlias resolution for dedup models. The canonical file may
|
|
136
|
-
// still export its raw name, so the import names must match the raw
|
|
137
|
-
// exports, not resolved aliases.
|
|
138
206
|
const skipTA = { skipTypeAlias: true };
|
|
139
207
|
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
140
208
|
const responseName = wireInterfaceName(domainName);
|
|
@@ -144,9 +212,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
144
212
|
const canonService = modelToService.get(canonicalName);
|
|
145
213
|
const canonDir = resolveDir(canonService);
|
|
146
214
|
|
|
147
|
-
// After noise suffix stripping (e.g., "OrganizationDto" → "Organization"),
|
|
148
|
-
// the alias and canonical may resolve to the same file path or the same
|
|
149
|
-
// type names. Skip — the canonical file already provides the types.
|
|
150
215
|
const aliasPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
151
216
|
const canonPath = `src/${canonDir}/interfaces/${fileName(canonicalName)}.interface.ts`;
|
|
152
217
|
if (aliasPath === canonPath) continue;
|
|
@@ -155,12 +220,37 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
155
220
|
canonDir === dirName
|
|
156
221
|
? `./${fileName(canonicalName)}.interface`
|
|
157
222
|
: `../../${canonDir}/interfaces/${fileName(canonicalName)}.interface`;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
223
|
+
|
|
224
|
+
// Single-form aliases: when the resolver collapses the IR model and
|
|
225
|
+
// its wire form to the same baseline name (or the alias's own
|
|
226
|
+
// domain/wire shapes coincide), emit only the unique exports. The
|
|
227
|
+
// earlier code unconditionally emitted both `export type X = Y;` and
|
|
228
|
+
// `export type X' = Y';` lines, producing TS2300 duplicate-identifier
|
|
229
|
+
// errors when X === X' and Y === Y'.
|
|
230
|
+
const aliasExports: string[] = [];
|
|
231
|
+
const importNeeded = new Set<string>();
|
|
232
|
+
const declared = new Set<string>();
|
|
233
|
+
const pushAlias = (lhs: string, rhs: string): void => {
|
|
234
|
+
if (lhs === rhs) return;
|
|
235
|
+
if (declared.has(lhs)) return;
|
|
236
|
+
declared.add(lhs);
|
|
237
|
+
aliasExports.push(`export type ${lhs} = ${rhs};`);
|
|
238
|
+
importNeeded.add(rhs);
|
|
239
|
+
};
|
|
240
|
+
pushAlias(domainName, canonDomainName);
|
|
241
|
+
pushAlias(responseName, canonResponseName);
|
|
242
|
+
if (aliasExports.length === 0) continue;
|
|
243
|
+
|
|
244
|
+
// Only import names that are referenced on the RHS of an alias AND
|
|
245
|
+
// aren't declared locally (which would shadow / collide with the
|
|
246
|
+
// import).
|
|
247
|
+
const importSymbols = [...importNeeded]
|
|
248
|
+
.filter((n) => !declared.has(n))
|
|
249
|
+
.sort()
|
|
250
|
+
.join(', ');
|
|
251
|
+
const aliasLines = importSymbols
|
|
252
|
+
? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
|
|
253
|
+
: [...aliasExports];
|
|
164
254
|
files.push({
|
|
165
255
|
path: aliasPath,
|
|
166
256
|
content: aliasLines.join('\n'),
|
|
@@ -169,19 +259,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
169
259
|
continue;
|
|
170
260
|
}
|
|
171
261
|
|
|
172
|
-
const service = modelToService.get(model.name);
|
|
173
262
|
const dirName = resolveDir(service);
|
|
174
|
-
// If this model is a dedup canonical (other models alias to it), skip
|
|
175
|
-
// typeAlias resolution so the file exports the raw name. Dedup aliases
|
|
176
|
-
// import using the raw name to stay consistent with preserved files.
|
|
177
263
|
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
178
264
|
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
179
265
|
const responseName = wireInterfaceName(domainName);
|
|
180
266
|
const deps = collectFieldDependencies(model);
|
|
181
267
|
const lines: string[] = [];
|
|
182
268
|
|
|
183
|
-
// Exclude the current model from generic defaults to avoid self-referencing
|
|
184
|
-
// (e.g., Profile's own fields should use TCustom, not Profile<Record<...>>)
|
|
185
269
|
let modelTypeRefOpts = typeRefOpts;
|
|
186
270
|
let modelWireTypeRefOpts = wireTypeRefOpts;
|
|
187
271
|
if (genericDefaults.has(model.name)) {
|
|
@@ -191,12 +275,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
191
275
|
modelWireTypeRefOpts = { genericDefaults: filteredDefaults };
|
|
192
276
|
}
|
|
193
277
|
|
|
194
|
-
// Baseline interface data (for compat field type matching)
|
|
195
278
|
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
196
279
|
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
197
280
|
|
|
198
|
-
// Build set of importable type names for this file:
|
|
199
|
-
// the model itself, its Response variant, all IR-dep model names + Response variants, and all IR-dep enum names
|
|
200
281
|
const importableNames = new Set<string>();
|
|
201
282
|
importableNames.add(domainName);
|
|
202
283
|
importableNames.add(responseName);
|
|
@@ -209,16 +290,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
209
290
|
importableNames.add(dep);
|
|
210
291
|
}
|
|
211
292
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// 3. Mark as unresolvable — the field will fall back to the IR-generated type
|
|
217
|
-
const typeDecls = new Map<string, string>(); // aliasName → type expression
|
|
218
|
-
const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
|
|
219
|
-
const unresolvableNames = new Set<string>(); // names that can't be resolved — forces IR fallback
|
|
220
|
-
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
221
|
-
// Build a lookup: resolved enum name → IR enum name
|
|
293
|
+
const typeDecls = new Map<string, string>();
|
|
294
|
+
const crossServiceImports = new Map<string, { name: string; relPath: string }>();
|
|
295
|
+
const unresolvableNames = new Set<string>();
|
|
296
|
+
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx);
|
|
222
297
|
const resolvedEnumNames = new Map<string, string>();
|
|
223
298
|
for (const e of ctx.spec.enums) {
|
|
224
299
|
resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
|
|
@@ -241,20 +316,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
241
316
|
if (crossServiceImports.has(name)) continue;
|
|
242
317
|
if (unresolvableNames.has(name)) continue;
|
|
243
318
|
|
|
244
|
-
// Check if this name exists as an enum in another service —
|
|
245
|
-
// import the actual type so the extractor sees the real name
|
|
246
319
|
const irEnumName = resolvedEnumNames.get(name);
|
|
247
320
|
if (irEnumName && !deps.enums.has(irEnumName)) {
|
|
248
321
|
const eService = enumToService.get(irEnumName);
|
|
249
322
|
const eDir = resolveDir(eService);
|
|
250
|
-
// Check baseline sourceFile — if the enum lives at a different path
|
|
251
|
-
// than the generated one, import from the baseline location.
|
|
252
323
|
const bEnum = ctx.apiSurface?.enums?.[irEnumName];
|
|
253
324
|
const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
|
|
254
325
|
const bSrc = (bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile;
|
|
255
326
|
const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
|
|
256
327
|
const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
257
|
-
// If defined inline in the same file, just add to importable names
|
|
258
328
|
if (bSrc === cPath) {
|
|
259
329
|
importableNames.add(name);
|
|
260
330
|
continue;
|
|
@@ -273,35 +343,56 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
273
343
|
continue;
|
|
274
344
|
}
|
|
275
345
|
|
|
276
|
-
// Try suffix match: find an importable name ending with this name
|
|
277
346
|
const candidates = [...importableNames].filter((n) => n.endsWith(name) && n !== name);
|
|
278
347
|
if (candidates.length === 1) {
|
|
279
|
-
// Create local type alias (e.g., type RoleResponse = ProfileRoleResponse)
|
|
280
348
|
typeDecls.set(name, candidates[0]);
|
|
281
349
|
importableNames.add(name);
|
|
282
350
|
} else {
|
|
283
|
-
// Cannot resolve this baseline type name — mark it so the field
|
|
284
|
-
// falls back to the IR-generated type instead of the baseline.
|
|
285
|
-
// This avoids creating type aliases that reference undefined types.
|
|
286
351
|
unresolvableNames.add(name);
|
|
287
352
|
}
|
|
288
353
|
}
|
|
289
354
|
}
|
|
290
355
|
}
|
|
291
356
|
|
|
292
|
-
// Import referenced models (domain + response) and enums with correct cross-service paths
|
|
293
357
|
for (const dep of deps.models) {
|
|
294
358
|
const depName = resolveInterfaceName(dep, ctx);
|
|
295
359
|
const depService = modelToService.get(dep);
|
|
296
360
|
const depDir = resolveDir(depService);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
361
|
+
|
|
362
|
+
// When the resolver maps the IR name to a different baseline interface
|
|
363
|
+
// (via `overlayLookup.modelNameByIR` structural match), the import
|
|
364
|
+
// path must follow the baseline's `sourceFile`. Otherwise we'd point
|
|
365
|
+
// at the IR-named file (e.g. `audit-log-event.interface`) that the
|
|
366
|
+
// emitter never generates — the canonical baseline file is at a
|
|
367
|
+
// different stem (e.g. `create-audit-log-event-options.interface`).
|
|
368
|
+
const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
369
|
+
const baselineSrc = (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
|
|
370
|
+
|
|
371
|
+
// Self-reference: the dependency lives in the file we're currently
|
|
372
|
+
// emitting. Skip the import — it's already in scope.
|
|
373
|
+
if (baselineSrc === currentFilePath) continue;
|
|
374
|
+
|
|
375
|
+
let relPath: string;
|
|
376
|
+
if (baselineSrc) {
|
|
377
|
+
relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
|
|
378
|
+
} else {
|
|
379
|
+
relPath =
|
|
380
|
+
depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// `wireInterfaceName` consults the baseline interface set so it
|
|
384
|
+
// returns the bare `depName` when the resolver mapped to a
|
|
385
|
+
// single-form interface (no separate `*Wire`). That keeps the
|
|
386
|
+
// import statement requesting only what the baseline file exports.
|
|
387
|
+
const wireName = wireInterfaceName(depName);
|
|
388
|
+
const importNames = wireName === depName ? depName : `${depName}, ${wireName}`;
|
|
389
|
+
lines.push(`import type { ${importNames} } from '${relPath}';`);
|
|
300
390
|
}
|
|
301
391
|
for (const dep of deps.enums) {
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
392
|
+
// Inlined enums are emitted as literal unions at the usage site
|
|
393
|
+
// (handled by type-map). Skip the import — the file does not exist.
|
|
394
|
+
if (isInlineEnum(dep)) continue;
|
|
395
|
+
|
|
305
396
|
const baselineEnum = ctx.apiSurface?.enums?.[dep];
|
|
306
397
|
const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
|
|
307
398
|
const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
|
|
@@ -310,8 +401,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
310
401
|
const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
|
|
311
402
|
const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
312
403
|
|
|
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
404
|
if (baselineSrc === currentFilePath) {
|
|
316
405
|
importableNames.add(dep);
|
|
317
406
|
continue;
|
|
@@ -319,7 +408,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
319
408
|
|
|
320
409
|
let relPath: string;
|
|
321
410
|
if (baselineSrc && baselineSrc !== generatedPath) {
|
|
322
|
-
// Baseline provides the enum from a different file — import from there.
|
|
323
411
|
relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
|
|
324
412
|
} else {
|
|
325
413
|
relPath =
|
|
@@ -333,17 +421,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
333
421
|
|
|
334
422
|
if (lines.length > 0) lines.push('');
|
|
335
423
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
// also get type parameter declarations on the interface itself.
|
|
424
|
+
// Type-alias declarations are pre-collected from baseline field types.
|
|
425
|
+
// The IR-driven body may end up not using them (the body uses the
|
|
426
|
+
// resolved interface names, while aliases serve only as bridges from
|
|
427
|
+
// baseline-name references inside `baselineField.type`). Defer their
|
|
428
|
+
// emission, then filter to only those names actually referenced in
|
|
429
|
+
// the body or wire interface lines.
|
|
430
|
+
const typeDeclInsertIdx = lines.length;
|
|
344
431
|
const typeParams = renderTypeParams(model, genericDefaults);
|
|
345
432
|
|
|
346
|
-
// Domain interface
|
|
433
|
+
// Domain interface
|
|
347
434
|
const seenDomainFields = new Set<string>();
|
|
348
435
|
if (model.description) {
|
|
349
436
|
lines.push(...docComment(model.description));
|
|
@@ -366,10 +453,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
366
453
|
lines.push(...docComment(parts.join('\n'), 2));
|
|
367
454
|
}
|
|
368
455
|
const baselineField = baselineDomain?.fields?.[domainFieldName];
|
|
369
|
-
// For the domain interface, also check that the response baseline's optionality
|
|
370
|
-
// is compatible — the serializer reads from the response type and assigns to the domain type.
|
|
371
|
-
// If the domain baseline says required but the response baseline says optional,
|
|
372
|
-
// the serializer would produce T | undefined for a field expecting T.
|
|
373
456
|
const domainWireField = wireFieldName(field.name);
|
|
374
457
|
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
375
458
|
const domainResponseOptionalMismatch =
|
|
@@ -385,23 +468,17 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
385
468
|
const opt = baselineField.optional ? '?' : '';
|
|
386
469
|
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
387
470
|
} else {
|
|
388
|
-
// When a baseline exists for this model, new fields (not present in the
|
|
389
|
-
// baseline) are generated as optional. The merger can deep-merge new
|
|
390
|
-
// fields into existing interfaces, but it cannot update existing
|
|
391
|
-
// deserializer function bodies. Making the field optional prevents a
|
|
392
|
-
// type error where the interface requires a field that the preserved
|
|
393
|
-
// deserializer never populates.
|
|
394
471
|
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
395
|
-
// Also make the field optional when the response baseline has it as optional
|
|
396
|
-
// but the domain baseline has it as required — the deserializer reads from
|
|
397
|
-
// the response type, so if the response field is optional, the domain value
|
|
398
|
-
// may be undefined.
|
|
399
|
-
// Additionally, when a baseline exists for the RESPONSE interface but NOT the
|
|
400
|
-
// domain interface, fields that are new on the response baseline become optional
|
|
401
|
-
// in the wire type. The domain type must also be optional to match, otherwise
|
|
402
|
-
// the deserializer produces T | undefined for a field typed as T.
|
|
403
472
|
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
473
|
+
// Preserve baseline-declared optionality even when we're emitting
|
|
474
|
+
// the IR-derived type (e.g. baseline `Date` for an IR string with
|
|
475
|
+
// `format: date-time`). Without this, regenerating an existing
|
|
476
|
+
// interface flips `external_id?: string` into `external_id: string
|
|
477
|
+
// | null`, which silently breaks every hand-written test fixture
|
|
478
|
+
// missing that field.
|
|
479
|
+
const baselineSaysOptional = baselineField?.optional === true;
|
|
404
480
|
const opt =
|
|
481
|
+
baselineSaysOptional ||
|
|
405
482
|
!field.required ||
|
|
406
483
|
isNewFieldOnExistingModel ||
|
|
407
484
|
domainResponseOptionalMismatch ||
|
|
@@ -412,10 +489,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
412
489
|
}
|
|
413
490
|
}
|
|
414
491
|
lines.push('}');
|
|
415
|
-
}
|
|
492
|
+
}
|
|
416
493
|
lines.push('');
|
|
417
494
|
|
|
418
|
-
// Wire/response interface
|
|
495
|
+
// Wire/response interface
|
|
419
496
|
const seenWireFields = new Set<string>();
|
|
420
497
|
if (model.fields.length === 0) {
|
|
421
498
|
lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
@@ -435,100 +512,107 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
435
512
|
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
436
513
|
} else {
|
|
437
514
|
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
438
|
-
|
|
515
|
+
// Same baseline-optional preservation as the domain side. The
|
|
516
|
+
// wire interface's optional flag drives test-fixture shape, so
|
|
517
|
+
// flipping it on regen breaks every fixture that omitted the
|
|
518
|
+
// field assuming it was optional.
|
|
519
|
+
const baselineSaysOptional = baselineField?.optional === true;
|
|
520
|
+
const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
439
521
|
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
440
522
|
}
|
|
441
523
|
}
|
|
442
524
|
lines.push('}');
|
|
443
|
-
}
|
|
525
|
+
}
|
|
444
526
|
|
|
445
|
-
//
|
|
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.
|
|
527
|
+
// Preserve inline types from existing file
|
|
448
528
|
const filePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
449
|
-
if (ctx.apiSurface) {
|
|
529
|
+
if (ctx.apiSurface && ctx.targetDir) {
|
|
450
530
|
const generatedNames = new Set<string>();
|
|
451
531
|
for (const line of lines) {
|
|
452
532
|
const m = line.match(/^export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/);
|
|
453
533
|
if (m) generatedNames.add(m[1]);
|
|
454
534
|
}
|
|
455
535
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
536
|
+
try {
|
|
537
|
+
const existingContent = fs.readFileSync(path.join(ctx.targetDir, filePath), 'utf-8');
|
|
538
|
+
const inlineNames = new Set<string>();
|
|
539
|
+
const checkSurface = (items: Record<string, any> | undefined) => {
|
|
540
|
+
if (!items) return;
|
|
541
|
+
for (const [name, item] of Object.entries(items)) {
|
|
542
|
+
const src = (item as any).sourceFile as string | undefined;
|
|
543
|
+
if (src !== filePath) continue;
|
|
544
|
+
if (generatedNames.has(name)) continue;
|
|
545
|
+
const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
|
|
546
|
+
if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
|
|
547
|
+
inlineNames.add(name);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
checkSurface(ctx.apiSurface.interfaces);
|
|
551
|
+
checkSurface(ctx.apiSurface.typeAliases);
|
|
552
|
+
checkSurface(ctx.apiSurface.enums);
|
|
553
|
+
|
|
554
|
+
if (inlineNames.size > 0) {
|
|
555
|
+
const existingLines = existingContent.split('\n');
|
|
556
|
+
let ei = 0;
|
|
557
|
+
while (ei < existingLines.length) {
|
|
558
|
+
const eline = existingLines[ei];
|
|
559
|
+
const dm = eline.match(/^(export\s+)?(?:interface|type|enum|class|const|function)\s+(\w+)/);
|
|
560
|
+
if (!dm || !inlineNames.has(dm[2])) {
|
|
561
|
+
ei++;
|
|
562
|
+
continue;
|
|
472
563
|
}
|
|
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
564
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
565
|
+
const block: string[] = [eline];
|
|
566
|
+
let braces = (eline.match(/\{/g) || []).length - (eline.match(/\}/g) || []).length;
|
|
567
|
+
if (braces === 0 && eline.includes(';')) {
|
|
568
|
+
lines.push('');
|
|
569
|
+
lines.push(block.join('\n'));
|
|
518
570
|
ei++;
|
|
519
|
-
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (braces === 0) {
|
|
574
|
+
ei++;
|
|
575
|
+
while (ei < existingLines.length) {
|
|
520
576
|
const nl = existingLines[ei];
|
|
521
577
|
block.push(nl);
|
|
522
|
-
braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
|
|
523
578
|
ei++;
|
|
579
|
+
if (
|
|
580
|
+
nl.trimEnd().endsWith(';') ||
|
|
581
|
+
(nl.trim() !== '' && !nl.trim().startsWith('|') && !nl.trim().startsWith('&'))
|
|
582
|
+
)
|
|
583
|
+
break;
|
|
524
584
|
}
|
|
525
585
|
lines.push('');
|
|
526
586
|
lines.push(block.join('\n'));
|
|
587
|
+
continue;
|
|
527
588
|
}
|
|
589
|
+
ei++;
|
|
590
|
+
while (ei < existingLines.length && braces > 0) {
|
|
591
|
+
const nl = existingLines[ei];
|
|
592
|
+
block.push(nl);
|
|
593
|
+
braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
|
|
594
|
+
ei++;
|
|
595
|
+
}
|
|
596
|
+
lines.push('');
|
|
597
|
+
lines.push(block.join('\n'));
|
|
528
598
|
}
|
|
529
|
-
} catch {
|
|
530
|
-
// No existing file — nothing to preserve
|
|
531
599
|
}
|
|
600
|
+
} catch {
|
|
601
|
+
// No existing file
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Splice in only the type aliases referenced by the body or wire lines.
|
|
606
|
+
if (typeDecls.size > 0) {
|
|
607
|
+
const bodyText = lines.slice(typeDeclInsertIdx).join('\n');
|
|
608
|
+
const usedDecls: string[] = [];
|
|
609
|
+
for (const [alias, typeExpr] of typeDecls) {
|
|
610
|
+
if (new RegExp(`\\b${alias}\\b`).test(bodyText)) {
|
|
611
|
+
usedDecls.push(`type ${alias} = ${typeExpr};`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (usedDecls.length > 0) {
|
|
615
|
+
lines.splice(typeDeclInsertIdx, 0, ...usedDecls, '');
|
|
532
616
|
}
|
|
533
617
|
}
|
|
534
618
|
|
|
@@ -542,129 +626,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
542
626
|
return files;
|
|
543
627
|
}
|
|
544
628
|
|
|
545
|
-
/**
|
|
546
|
-
* Check if all PascalCase type references in a baseline type string
|
|
547
|
-
* can be resolved to types that are actually importable in the generated file.
|
|
548
|
-
* A type is importable if it's a builtin, or if it's among the set of names
|
|
549
|
-
* that will be imported (the model's own name/response, or its IR deps).
|
|
550
|
-
* Returns false if any reference is unresolvable (e.g., hand-written types
|
|
551
|
-
* from the live SDK, or spec types from other services not in IR deps).
|
|
552
|
-
*/
|
|
553
|
-
function baselineTypeResolvable(typeStr: string, importableNames: Set<string>): boolean {
|
|
554
|
-
const matches = typeStr.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
555
|
-
if (!matches) return true;
|
|
556
|
-
|
|
557
|
-
for (const name of matches) {
|
|
558
|
-
if (TS_BUILTINS.has(name)) continue;
|
|
559
|
-
if (importableNames.has(name)) continue;
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
562
|
-
return true;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Check if a baseline field type is compatible with the IR field for use
|
|
567
|
-
* in the generated interface. The serializer generates expressions based on
|
|
568
|
-
* the IR type, so the interface type must be assignable from the serializer output.
|
|
569
|
-
*
|
|
570
|
-
* Rejects baseline types when:
|
|
571
|
-
* - IR field is nullable but baseline type doesn't include `null`
|
|
572
|
-
* - IR field is optional but baseline says required (and vice versa)
|
|
573
|
-
* - IR field is required but baseline says optional
|
|
574
|
-
*/
|
|
575
|
-
function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
|
|
576
|
-
const irNullable = irField.type.kind === 'nullable';
|
|
577
|
-
const baselineHasNull = baselineField.type.includes('null');
|
|
578
|
-
|
|
579
|
-
// If the IR field is nullable, the serializer produces `expr ?? null`,
|
|
580
|
-
// so the baseline type must include null to be assignable.
|
|
581
|
-
// Exception: for optional fields, the serializer's null guard converts
|
|
582
|
-
// null to undefined (`wireAccess != null ? expr : undefined`), so the
|
|
583
|
-
// result type is `T | undefined` which is compatible with `field?: T`.
|
|
584
|
-
if (irNullable && !baselineHasNull && irField.required) {
|
|
585
|
-
return false;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// If the IR field is optional, the serializer may produce undefined,
|
|
589
|
-
// so the baseline should also be optional (or include undefined)
|
|
590
|
-
if (!irField.required && !baselineField.optional && !baselineField.type.includes('undefined')) {
|
|
591
|
-
return false;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// If the IR field is required but the baseline says optional,
|
|
595
|
-
// the serializer produces a definite value but the interface is looser — that's OK
|
|
596
|
-
// (the domain type is wider than the serializer output)
|
|
597
|
-
|
|
598
|
-
// If the baseline type is Record<string, unknown> but the IR field has a more specific
|
|
599
|
-
// type (model, enum, or union with named variants), prefer the IR type for better type safety
|
|
600
|
-
if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
|
|
601
|
-
return false;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/** Check if an IR type is more specific than Record<string, unknown>. */
|
|
608
|
-
function hasSpecificIRType(ref: TypeRef): boolean {
|
|
609
|
-
switch (ref.kind) {
|
|
610
|
-
case 'model':
|
|
611
|
-
case 'enum':
|
|
612
|
-
return true;
|
|
613
|
-
case 'union':
|
|
614
|
-
// A union with named model/enum variants is more specific
|
|
615
|
-
return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
|
|
616
|
-
case 'nullable':
|
|
617
|
-
return hasSpecificIRType(ref.inner);
|
|
618
|
-
default:
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
|
|
624
|
-
if (!model.typeParams?.length) {
|
|
625
|
-
// Fallback: if genericDefaults indicates this model is generic (detected
|
|
626
|
-
// from the baseline), generate a default generic type parameter declaration.
|
|
627
|
-
if (genericDefaults?.has(model.name)) {
|
|
628
|
-
return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
|
|
629
|
-
}
|
|
630
|
-
return '';
|
|
631
|
-
}
|
|
632
|
-
const params = model.typeParams.map((tp) => {
|
|
633
|
-
const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
|
|
634
|
-
return `${tp.name}${def}`;
|
|
635
|
-
});
|
|
636
|
-
return `<${params.join(', ')}>`;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// ---------------------------------------------------------------------------
|
|
640
|
-
// Shared context — computed once and reused by interface + serializer passes
|
|
641
|
-
// ---------------------------------------------------------------------------
|
|
642
|
-
|
|
643
|
-
interface SharedModelContext {
|
|
644
|
-
modelToService: Map<string, string>;
|
|
645
|
-
resolveDir: (irService: string | undefined) => string;
|
|
646
|
-
dedup: Map<string, string>;
|
|
647
|
-
genericDefaults: Map<string, string>;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
651
|
-
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
652
|
-
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
653
|
-
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
654
|
-
const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
655
|
-
const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
|
|
656
|
-
return { modelToService, resolveDir, dedup, genericDefaults };
|
|
657
|
-
}
|
|
658
|
-
|
|
659
629
|
// ---------------------------------------------------------------------------
|
|
660
|
-
// Serializer
|
|
630
|
+
// Serializer generation
|
|
661
631
|
// ---------------------------------------------------------------------------
|
|
662
632
|
|
|
663
|
-
/**
|
|
664
|
-
* Generate serializer files for all models.
|
|
665
|
-
* Can accept pre-computed shared context to avoid duplicating work
|
|
666
|
-
* when called alongside generateModels.
|
|
667
|
-
*/
|
|
668
633
|
export function generateSerializers(
|
|
669
634
|
models: Model[],
|
|
670
635
|
ctx: EmitterContext,
|
|
@@ -675,42 +640,55 @@ export function generateSerializers(
|
|
|
675
640
|
const { modelToService, resolveDir, dedup } = shared ?? buildSharedContext(models, ctx);
|
|
676
641
|
const files: GeneratedFile[] = [];
|
|
677
642
|
const skippedSerializeModels = new Set<string>();
|
|
643
|
+
const projectedModels = models.map((model) =>
|
|
644
|
+
projectModelToManagedSurface(model, { modelToService, resolveDir, dedup, genericDefaults: new Map() }, ctx),
|
|
645
|
+
);
|
|
646
|
+
const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
|
|
647
|
+
const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
|
|
648
|
+
const serializerEligibleModels = resourceUsage
|
|
649
|
+
? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
|
|
650
|
+
: undefined;
|
|
678
651
|
|
|
679
|
-
// Reuse the same reachability set from generateModels to skip serializers
|
|
680
|
-
// for unreachable models (e.g., event/webhook payload types).
|
|
681
652
|
const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
|
|
682
653
|
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
654
|
+
// Detect models whose serializer file already exists in the live SDK but
|
|
655
|
+
// does not export a `serialize<Domain>` function. Generated serializers
|
|
656
|
+
// that reference such a model must skip their `serialize` half — calling
|
|
657
|
+
// a missing function would leave the SDK unable to compile.
|
|
658
|
+
const liveRoot = ctx.targetDir ?? ctx.outputDir;
|
|
659
|
+
if (liveRoot) {
|
|
660
|
+
for (const originalModel of models) {
|
|
661
|
+
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
687
662
|
if (!serializerReachable.has(model.name)) continue;
|
|
688
|
-
if (
|
|
663
|
+
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
689
664
|
const service = modelToService.get(model.name);
|
|
690
665
|
const dirName = resolveDir(service);
|
|
691
666
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
);
|
|
667
|
+
const baselineSource = (ctx.apiSurface?.interfaces?.[domainName] as { sourceFile?: string } | undefined)
|
|
668
|
+
?.sourceFile;
|
|
669
|
+
const serializerRelPath = baselineSource
|
|
670
|
+
? baselineSource.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
|
|
671
|
+
: `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
672
|
+
const serializerFile = path.join(liveRoot, serializerRelPath);
|
|
699
673
|
try {
|
|
700
674
|
const content = fs.readFileSync(serializerFile, 'utf-8');
|
|
701
|
-
|
|
675
|
+
const isGeneratedFile =
|
|
676
|
+
ctx.priorTargetManifestPaths?.has(serializerRelPath) ||
|
|
677
|
+
/auto-generated by oagen/i.test(content.slice(0, 400));
|
|
678
|
+
if (!isGeneratedFile && !new RegExp(`\\bserialize${domainName}\\b`).test(content)) {
|
|
702
679
|
skippedSerializeModels.add(model.name);
|
|
703
680
|
}
|
|
704
681
|
} catch {
|
|
705
|
-
// Serializer doesn't exist
|
|
682
|
+
// Serializer doesn't exist on disk yet — fine, we'll generate one.
|
|
706
683
|
}
|
|
707
684
|
}
|
|
708
685
|
}
|
|
709
686
|
|
|
710
|
-
// Force-generate serializers for inline dependency models (same logic as interfaces).
|
|
711
687
|
const forceGenerateSerializer = new Set<string>();
|
|
712
|
-
for (const
|
|
688
|
+
for (const originalModel of models) {
|
|
689
|
+
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
713
690
|
if (!serializerReachable.has(model.name)) continue;
|
|
691
|
+
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
714
692
|
if (!modelHasNewFields(model, ctx)) continue;
|
|
715
693
|
const service = modelToService.get(model.name);
|
|
716
694
|
const dirName = resolveDir(service);
|
|
@@ -726,21 +704,23 @@ export function generateSerializers(
|
|
|
726
704
|
}
|
|
727
705
|
}
|
|
728
706
|
|
|
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
707
|
const eligibleModels: Model[] = [];
|
|
733
|
-
for (const
|
|
708
|
+
for (const originalModel of models) {
|
|
709
|
+
const model = projectedByName.get(originalModel.name) ?? originalModel;
|
|
734
710
|
if (!serializerReachable.has(model.name)) continue;
|
|
711
|
+
if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
|
|
735
712
|
if (isListMetadataModel(model)) continue;
|
|
736
713
|
if (isListWrapperModel(model)) continue;
|
|
737
|
-
|
|
714
|
+
const service = modelToService.get(model.name);
|
|
715
|
+
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
716
|
+
if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
|
|
738
717
|
eligibleModels.push(model);
|
|
739
718
|
}
|
|
719
|
+
(ctx as any)._generatedSerializerModels = new Set(eligibleModels.map((model) => model.name));
|
|
740
720
|
|
|
741
|
-
//
|
|
721
|
+
// Pass 1: determine shouldSkipSerialize
|
|
742
722
|
for (const model of eligibleModels) {
|
|
743
|
-
if (dedup.has(model.name)) continue;
|
|
723
|
+
if (dedup.has(model.name)) continue;
|
|
744
724
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
745
725
|
const responseName = wireInterfaceName(domainName);
|
|
746
726
|
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
@@ -758,14 +738,13 @@ export function generateSerializers(
|
|
|
758
738
|
}
|
|
759
739
|
}
|
|
760
740
|
|
|
761
|
-
//
|
|
741
|
+
// Pass 2: generate serializer files
|
|
762
742
|
for (const model of eligibleModels) {
|
|
763
|
-
|
|
743
|
+
const service = modelToService.get(model.name);
|
|
744
|
+
const isOwnedModel = isNodeOwnedService(ctx, service);
|
|
764
745
|
const canonicalName = dedup.get(model.name);
|
|
765
|
-
if (canonicalName) {
|
|
766
|
-
const service = modelToService.get(model.name);
|
|
746
|
+
if (canonicalName && !isOwnedModel) {
|
|
767
747
|
const dirName = resolveDir(service);
|
|
768
|
-
// Skip typeAlias resolution for dedup serializers (same reason as interfaces).
|
|
769
748
|
const skipTA = { skipTypeAlias: true };
|
|
770
749
|
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
771
750
|
const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
|
|
@@ -775,9 +754,6 @@ export function generateSerializers(
|
|
|
775
754
|
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
776
755
|
const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
|
|
777
756
|
|
|
778
|
-
// After noise suffix stripping, alias and canonical may resolve to the
|
|
779
|
-
// same serializer path or the same function names. Skip — the canonical
|
|
780
|
-
// serializer already provides the functions.
|
|
781
757
|
if (serializerPath === canonSerializerPath) continue;
|
|
782
758
|
if (domainName === canonDomainName) continue;
|
|
783
759
|
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
@@ -793,7 +769,6 @@ export function generateSerializers(
|
|
|
793
769
|
continue;
|
|
794
770
|
}
|
|
795
771
|
|
|
796
|
-
const service = modelToService.get(model.name);
|
|
797
772
|
const dirName = resolveDir(service);
|
|
798
773
|
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
799
774
|
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
@@ -829,23 +804,237 @@ export function generateSerializers(
|
|
|
829
804
|
});
|
|
830
805
|
}
|
|
831
806
|
|
|
832
|
-
// Stash the fully-computed skip set on the context so the test generator
|
|
833
|
-
// can read it without duplicating the detection logic.
|
|
834
807
|
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
|
835
808
|
|
|
836
809
|
return files;
|
|
837
810
|
}
|
|
838
811
|
|
|
839
812
|
// ---------------------------------------------------------------------------
|
|
840
|
-
// Combined generation
|
|
813
|
+
// Combined generation
|
|
841
814
|
// ---------------------------------------------------------------------------
|
|
842
815
|
|
|
843
|
-
/**
|
|
844
|
-
* Generate both interface files and serializer files in a single pass
|
|
845
|
-
* with shared context computation.
|
|
846
|
-
*/
|
|
847
816
|
export function generateModelsAndSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
848
817
|
if (models.length === 0) return [];
|
|
849
818
|
const shared = buildSharedContext(models, ctx);
|
|
850
819
|
return [...generateModels(models, ctx, shared), ...generateSerializers(models, ctx, shared)];
|
|
851
820
|
}
|
|
821
|
+
|
|
822
|
+
export function generatedResourceInterfaceModelNames(models: Model[], ctx: EmitterContext): Set<string> | undefined {
|
|
823
|
+
const shared = buildSharedContext(models, ctx);
|
|
824
|
+
const projectedModels = models.map((model) => projectModelToManagedSurface(model, shared, ctx));
|
|
825
|
+
const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
|
|
826
|
+
const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
|
|
827
|
+
return resourceUsage ? expandModelRoots(resourceUsage.interfaceRoots, projectedByName) : undefined;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Helpers
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
function buildGeneratedResourceModelUsage(
|
|
835
|
+
models: Model[],
|
|
836
|
+
ctx: EmitterContext,
|
|
837
|
+
): GeneratedResourceModelUsage | undefined {
|
|
838
|
+
if (ctx.spec.services.length === 0) return undefined;
|
|
839
|
+
|
|
840
|
+
const modelMap = new Map(models.map((model) => [model.name, model]));
|
|
841
|
+
const interfaceRoots = new Set<string>();
|
|
842
|
+
const serializerRoots = new Set<string>();
|
|
843
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
844
|
+
const mountGroups = groupByMount(ctx);
|
|
845
|
+
const services: Service[] =
|
|
846
|
+
mountGroups.size > 0
|
|
847
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
848
|
+
: ctx.spec.services;
|
|
849
|
+
|
|
850
|
+
for (const service of services) {
|
|
851
|
+
const resourceClass = resolveResourceClassName(service, ctx);
|
|
852
|
+
const isOwnedService = isNodeOwnedService(ctx, service.name, resourceClass);
|
|
853
|
+
const baselineHasResourceClass = Boolean(ctx.apiSurface?.classes?.[resourceClass]);
|
|
854
|
+
|
|
855
|
+
if (
|
|
856
|
+
!isOwnedService &&
|
|
857
|
+
baselineHasResourceClass &&
|
|
858
|
+
isServiceCoveredByExisting(service, ctx) &&
|
|
859
|
+
!hasMethodsAbsentFromBaseline(service, ctx)
|
|
860
|
+
) {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
let plans = service.operations.map((op) => ({
|
|
865
|
+
op,
|
|
866
|
+
plan: planOperation(op),
|
|
867
|
+
method: resolveMethodName(op, service, ctx),
|
|
868
|
+
}));
|
|
869
|
+
|
|
870
|
+
const baselineMethodNames = new Set(Object.keys(ctx.apiSurface?.classes?.[resourceClass]?.methods ?? {}));
|
|
871
|
+
if (!isOwnedService && baselineMethodNames.size > 0) {
|
|
872
|
+
plans = plans.filter((p) => !baselineMethodNames.has(p.method));
|
|
873
|
+
}
|
|
874
|
+
if (plans.length === 0) continue;
|
|
875
|
+
|
|
876
|
+
for (const { op, plan } of plans) {
|
|
877
|
+
if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
|
|
878
|
+
let itemName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : undefined;
|
|
879
|
+
if (itemName) {
|
|
880
|
+
const itemModel = modelMap.get(itemName);
|
|
881
|
+
const unwrapped = itemModel ? unwrapListModel(itemModel, modelMap) : null;
|
|
882
|
+
if (unwrapped) itemName = unwrapped.name;
|
|
883
|
+
interfaceRoots.add(itemName);
|
|
884
|
+
serializerRoots.add(itemName);
|
|
885
|
+
}
|
|
886
|
+
} else if (plan.responseModelName) {
|
|
887
|
+
interfaceRoots.add(plan.responseModelName);
|
|
888
|
+
serializerRoots.add(plan.responseModelName);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const bodyInfo = extractRequestBodyModels(op, ctx);
|
|
892
|
+
for (const name of bodyInfo) {
|
|
893
|
+
interfaceRoots.add(name);
|
|
894
|
+
serializerRoots.add(name);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
898
|
+
collectTypeRefModels(param.type, interfaceRoots);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
902
|
+
if (resolved) {
|
|
903
|
+
for (const name of collectWrapperResponseModels(resolved)) {
|
|
904
|
+
interfaceRoots.add(name);
|
|
905
|
+
serializerRoots.add(name);
|
|
906
|
+
}
|
|
907
|
+
for (const wrapper of resolved.wrappers ?? []) {
|
|
908
|
+
for (const { field } of resolveWrapperParams(wrapper, ctx)) {
|
|
909
|
+
if (field) collectTypeRefModels(field.type, interfaceRoots);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { interfaceRoots, serializerRoots };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
|
|
920
|
+
if (!op.requestBody) return [];
|
|
921
|
+
if (op.requestBody.kind === 'model') return [op.requestBody.name];
|
|
922
|
+
if (op.requestBody.kind !== 'union') return [];
|
|
923
|
+
|
|
924
|
+
const names: string[] = [];
|
|
925
|
+
for (const variant of op.requestBody.variants) {
|
|
926
|
+
if (variant.kind === 'model') names.push(variant.name);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return names.length > 0 ? names : collectDiscriminatorModelNames(op.requestBody.discriminator, ctx);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function collectDiscriminatorModelNames(
|
|
933
|
+
discriminator: { mapping?: Record<string, string> } | undefined,
|
|
934
|
+
ctx: EmitterContext,
|
|
935
|
+
): string[] {
|
|
936
|
+
const names = new Set<string>();
|
|
937
|
+
for (const mapped of Object.values(discriminator?.mapping ?? {})) {
|
|
938
|
+
const name = mapped.split('/').pop();
|
|
939
|
+
if (name && ctx.spec.models.some((model) => model.name === name)) names.add(name);
|
|
940
|
+
}
|
|
941
|
+
return [...names];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function collectTypeRefModels(ref: TypeRef | undefined, out: Set<string>): void {
|
|
945
|
+
if (!ref) return;
|
|
946
|
+
switch (ref.kind) {
|
|
947
|
+
case 'model':
|
|
948
|
+
out.add(ref.name);
|
|
949
|
+
return;
|
|
950
|
+
case 'array':
|
|
951
|
+
collectTypeRefModels(ref.items, out);
|
|
952
|
+
return;
|
|
953
|
+
case 'nullable':
|
|
954
|
+
collectTypeRefModels(ref.inner, out);
|
|
955
|
+
return;
|
|
956
|
+
case 'union':
|
|
957
|
+
for (const variant of ref.variants) collectTypeRefModels(variant, out);
|
|
958
|
+
return;
|
|
959
|
+
default:
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function expandModelRoots(roots: Set<string>, modelsByName: Map<string, Model>): Set<string> {
|
|
965
|
+
const out = new Set<string>();
|
|
966
|
+
const queue = [...roots];
|
|
967
|
+
|
|
968
|
+
while (queue.length > 0) {
|
|
969
|
+
const name = queue.pop()!;
|
|
970
|
+
if (out.has(name)) continue;
|
|
971
|
+
const model = modelsByName.get(name);
|
|
972
|
+
if (!model) continue;
|
|
973
|
+
out.add(name);
|
|
974
|
+
|
|
975
|
+
for (const dep of collectFieldDependencies(model).models) {
|
|
976
|
+
if (!out.has(dep)) queue.push(dep);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return out;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function baselineTypeResolvable(typeStr: string, importableNames: Set<string>): boolean {
|
|
984
|
+
const matches = typeStr.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
|
|
985
|
+
if (!matches) return true;
|
|
986
|
+
|
|
987
|
+
for (const name of matches) {
|
|
988
|
+
if (TS_BUILTINS.has(name)) continue;
|
|
989
|
+
if (importableNames.has(name)) continue;
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
|
|
996
|
+
const irNullable = irField.type.kind === 'nullable';
|
|
997
|
+
const baselineHasNull = baselineField.type.includes('null');
|
|
998
|
+
|
|
999
|
+
if (irNullable && !baselineHasNull && irField.required) {
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (!irField.required && !baselineField.optional && !baselineField.type.includes('undefined')) {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function hasSpecificIRType(ref: TypeRef): boolean {
|
|
1015
|
+
switch (ref.kind) {
|
|
1016
|
+
case 'model':
|
|
1017
|
+
case 'enum':
|
|
1018
|
+
return true;
|
|
1019
|
+
case 'union':
|
|
1020
|
+
return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
|
|
1021
|
+
case 'nullable':
|
|
1022
|
+
return hasSpecificIRType(ref.inner);
|
|
1023
|
+
default:
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
|
|
1029
|
+
if (!model.typeParams?.length) {
|
|
1030
|
+
if (genericDefaults?.has(model.name)) {
|
|
1031
|
+
return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
|
|
1032
|
+
}
|
|
1033
|
+
return '';
|
|
1034
|
+
}
|
|
1035
|
+
const params = model.typeParams.map((tp) => {
|
|
1036
|
+
const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
|
|
1037
|
+
return `${tp.name}${def}`;
|
|
1038
|
+
});
|
|
1039
|
+
return `<${params.join(', ')}>`;
|
|
1040
|
+
}
|