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