@workos/oagen-emitters 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +328 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +189 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +682 -0
- package/test/php/tests.test.ts +185 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
package/src/node/serializers.ts
DELETED
|
@@ -1,746 +0,0 @@
|
|
|
1
|
-
import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType, PrimitiveType } from '@workos/oagen';
|
|
2
|
-
import { mapTypeRef as tsMapTypeRef } from './type-map.js';
|
|
3
|
-
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
|
-
import {
|
|
5
|
-
relativeImport,
|
|
6
|
-
pruneUnusedImports,
|
|
7
|
-
detectStringDateConvention,
|
|
8
|
-
buildKnownTypeNames,
|
|
9
|
-
isBaselineGeneric,
|
|
10
|
-
createServiceDirResolver,
|
|
11
|
-
isListMetadataModel,
|
|
12
|
-
isListWrapperModel,
|
|
13
|
-
buildDeduplicationMap,
|
|
14
|
-
} from './utils.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Render generic type parameter declarations for a model.
|
|
18
|
-
* E.g., `<CustomAttributesType = Record<string, unknown>>`.
|
|
19
|
-
* Returns empty string for non-generic models.
|
|
20
|
-
*/
|
|
21
|
-
function renderSerializerTypeParams(model: Model, ctx?: EmitterContext): { decl: string; usage: string } {
|
|
22
|
-
if (model.typeParams?.length) {
|
|
23
|
-
const params = model.typeParams.map((tp) => {
|
|
24
|
-
const def = tp.default ? ` = ${tsMapTypeRef(tp.default)}` : '';
|
|
25
|
-
return `${tp.name}${def}`;
|
|
26
|
-
});
|
|
27
|
-
const names = model.typeParams.map((tp) => tp.name);
|
|
28
|
-
return { decl: `<${params.join(', ')}>`, usage: `<${names.join(', ')}>` };
|
|
29
|
-
}
|
|
30
|
-
// Fallback: check if the baseline interface is generic (hand-written generics
|
|
31
|
-
// not captured in the IR). Only apply if the baseline file path matches the
|
|
32
|
-
// generated path — meaning the existing generic file will be preserved via
|
|
33
|
-
// skipIfExists. If paths differ, the interface is newly generated and non-generic.
|
|
34
|
-
if (ctx?.apiSurface?.interfaces) {
|
|
35
|
-
const domainName = resolveInterfaceName(model.name, ctx);
|
|
36
|
-
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
37
|
-
if (baseline?.fields) {
|
|
38
|
-
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
39
|
-
const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
40
|
-
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
41
|
-
const pathMatches = !baselineSourceFile || baselineSourceFile === generatedPath;
|
|
42
|
-
const knownNames = buildKnownTypeNames(ctx.spec.models, ctx);
|
|
43
|
-
if (pathMatches && isBaselineGeneric(baseline.fields, knownNames)) {
|
|
44
|
-
return {
|
|
45
|
-
decl: '<GenericType extends Record<string, unknown> = Record<string, unknown>>',
|
|
46
|
-
usage: '<GenericType>',
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { decl: '', usage: '' };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function generateSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
55
|
-
if (models.length === 0) return [];
|
|
56
|
-
|
|
57
|
-
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
58
|
-
const useStringDates = detectStringDateConvention(models, ctx);
|
|
59
|
-
const files: GeneratedFile[] = [];
|
|
60
|
-
const dedup = buildDeduplicationMap(models, ctx);
|
|
61
|
-
// Track model names whose serialize function was skipped due to baseline incompatibility.
|
|
62
|
-
// Dependent serializers that import a skipped serialize function must also skip.
|
|
63
|
-
const skippedSerializeModels = new Set<string>();
|
|
64
|
-
|
|
65
|
-
for (const model of models) {
|
|
66
|
-
// Fix #5: Skip per-domain ListMetadata serializers — the shared deserializeListMetadata covers these
|
|
67
|
-
if (isListMetadataModel(model)) continue;
|
|
68
|
-
|
|
69
|
-
// Fix #7: Skip per-domain list wrapper serializers — the shared deserializeList covers these
|
|
70
|
-
if (isListWrapperModel(model)) continue;
|
|
71
|
-
|
|
72
|
-
// Deduplication: for structurally identical models, re-export the canonical serializer
|
|
73
|
-
const canonicalName = dedup.get(model.name);
|
|
74
|
-
if (canonicalName) {
|
|
75
|
-
const domainName = resolveInterfaceName(model.name, ctx);
|
|
76
|
-
const canonDomainName = resolveInterfaceName(canonicalName, ctx);
|
|
77
|
-
const service = modelToService.get(model.name);
|
|
78
|
-
const dirName = resolveDir(service);
|
|
79
|
-
const canonService = modelToService.get(canonicalName);
|
|
80
|
-
const canonDir = resolveDir(canonService);
|
|
81
|
-
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
82
|
-
const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
|
|
83
|
-
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
84
|
-
const aliasLines = [
|
|
85
|
-
`export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`,
|
|
86
|
-
];
|
|
87
|
-
files.push({
|
|
88
|
-
path: serializerPath,
|
|
89
|
-
content: aliasLines.join('\n'),
|
|
90
|
-
});
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const service = modelToService.get(model.name);
|
|
95
|
-
const dirName = resolveDir(service);
|
|
96
|
-
const domainName = resolveInterfaceName(model.name, ctx);
|
|
97
|
-
const responseName = wireInterfaceName(domainName);
|
|
98
|
-
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
99
|
-
const typeParams = renderSerializerTypeParams(model, ctx);
|
|
100
|
-
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
101
|
-
|
|
102
|
-
// Build a set of field names where format conversion (new Date / BigInt) should
|
|
103
|
-
// be skipped. When the SDK-wide convention is string dates, ALL date-time fields
|
|
104
|
-
// in ALL models skip conversion — not just those with a baseline interface.
|
|
105
|
-
const skipFormatFields = new Set<string>();
|
|
106
|
-
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
107
|
-
|
|
108
|
-
// Check if the serialize function would produce type errors against the baseline
|
|
109
|
-
// wire interface. Skip serialize generation when the baseline has required fields
|
|
110
|
-
// that aren't in the IR model — the generated serialize body would be missing those
|
|
111
|
-
// fields, causing TS2741 / TS2322 errors. The merger will preserve any existing
|
|
112
|
-
// hand-written serialize function.
|
|
113
|
-
let shouldSkipSerialize = serializerHasBaselineIncompatibility(model, baselineResponse, baselineDomain, ctx);
|
|
114
|
-
// Also skip if any nested model dependency had its serialize skipped — the generated
|
|
115
|
-
// serialize function would reference a non-existent serialize export.
|
|
116
|
-
if (!shouldSkipSerialize) {
|
|
117
|
-
for (const field of model.fields) {
|
|
118
|
-
for (const ref of collectSerializedModelRefs(field.type)) {
|
|
119
|
-
// Check both the original model name and its dedup canonical name
|
|
120
|
-
if (skippedSerializeModels.has(ref)) {
|
|
121
|
-
shouldSkipSerialize = true;
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
const canon = dedup.get(ref);
|
|
125
|
-
if (canon && skippedSerializeModels.has(canon)) {
|
|
126
|
-
shouldSkipSerialize = true;
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (shouldSkipSerialize) break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (shouldSkipSerialize) {
|
|
134
|
-
skippedSerializeModels.add(model.name);
|
|
135
|
-
}
|
|
136
|
-
if (useStringDates) {
|
|
137
|
-
// Global convention: skip date-time conversion for every date field
|
|
138
|
-
for (const field of model.fields) {
|
|
139
|
-
if (hasDateTimeConversion(field.type)) {
|
|
140
|
-
skipFormatFields.add(field.name);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (baselineDomain) {
|
|
145
|
-
// Per-field baseline check: also skip any other format conversions
|
|
146
|
-
// (e.g., int64 → BigInt) when the baseline uses a simpler type
|
|
147
|
-
for (const field of model.fields) {
|
|
148
|
-
if (skipFormatFields.has(field.name)) continue;
|
|
149
|
-
const baselineField = baselineDomain.fields?.[fieldName(field.name)];
|
|
150
|
-
if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
|
|
151
|
-
skipFormatFields.add(field.name);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Find nested model refs that need their own serializer imports.
|
|
157
|
-
// Only collect models that will actually be called in serialize/deserialize expressions
|
|
158
|
-
// (direct model refs, array-of-model items, nullable-wrapped models, single-model-variant unions).
|
|
159
|
-
const nestedModelRefs = new Set<string>();
|
|
160
|
-
for (const field of model.fields) {
|
|
161
|
-
for (const ref of collectSerializedModelRefs(field.type)) {
|
|
162
|
-
if (ref !== model.name) nestedModelRefs.add(ref);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const lines: string[] = [];
|
|
167
|
-
|
|
168
|
-
// Import model interfaces
|
|
169
|
-
const interfacePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
170
|
-
lines.push(
|
|
171
|
-
`import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`,
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
// Import nested model deserializers/serializers as a single merged import.
|
|
175
|
-
// pruneUnusedImports will strip any unused identifiers (e.g., serialize*
|
|
176
|
-
// when shouldSkipSerialize is true).
|
|
177
|
-
for (const dep of nestedModelRefs) {
|
|
178
|
-
const depService = modelToService.get(dep);
|
|
179
|
-
const depDir = resolveDir(depService);
|
|
180
|
-
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
181
|
-
const depName = resolveInterfaceName(dep, ctx);
|
|
182
|
-
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
183
|
-
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
184
|
-
}
|
|
185
|
-
lines.push('');
|
|
186
|
-
|
|
187
|
-
// Deserialize function (wire → domain) — deduplicate by camelCase name
|
|
188
|
-
const seenDeserFields = new Set<string>();
|
|
189
|
-
// Prefix param with _ when model has no fields to avoid unused-param warnings
|
|
190
|
-
const deserParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
191
|
-
lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
|
|
192
|
-
lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
|
|
193
|
-
lines.push(`): ${domainName}${typeParams.usage} => ({`);
|
|
194
|
-
for (const field of model.fields) {
|
|
195
|
-
const domain = fieldName(field.name);
|
|
196
|
-
if (seenDeserFields.has(domain)) continue;
|
|
197
|
-
seenDeserFields.add(domain);
|
|
198
|
-
const wire = wireFieldName(field.name);
|
|
199
|
-
const wireAccess = `response.${wire}`;
|
|
200
|
-
const skip = skipFormatFields.has(field.name);
|
|
201
|
-
const expr = skip ? wireAccess : deserializeExpression(field.type, wireAccess, ctx);
|
|
202
|
-
// Treat new fields (not in baseline) as effectively optional: the merger
|
|
203
|
-
// can deep-merge them into existing interfaces but cannot update existing
|
|
204
|
-
// deserializer bodies, so the wire response may not contain them.
|
|
205
|
-
const isNewField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
206
|
-
const effectivelyOptional = !field.required || isNewField;
|
|
207
|
-
// If the field is optional and the expression involves a function call,
|
|
208
|
-
// wrap with a null check to avoid passing undefined to the deserializer.
|
|
209
|
-
// When the field type is nullable, preserve null on the wire instead of
|
|
210
|
-
// converting it to undefined (APIs distinguish null from absent).
|
|
211
|
-
if (effectivelyOptional && expr !== wireAccess && needsNullGuard(field.type)) {
|
|
212
|
-
const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
213
|
-
// If the expression already starts with a null guard from nullable handling,
|
|
214
|
-
// don't wrap it again — just replace the inner null fallback
|
|
215
|
-
if (expr.startsWith(`${wireAccess} != null ?`)) {
|
|
216
|
-
lines.push(` ${domain}: ${expr.replace(/: null$/, `: ${fallback}`)},`);
|
|
217
|
-
} else {
|
|
218
|
-
lines.push(` ${domain}: ${wireAccess} != null ? ${expr} : ${fallback},`);
|
|
219
|
-
}
|
|
220
|
-
} else if (field.required && expr === wireAccess) {
|
|
221
|
-
// Required field with direct assignment — only add a fallback when the
|
|
222
|
-
// response interface makes the field optional (baseline override mismatch)
|
|
223
|
-
// or the field is newly added. When the field is required on BOTH
|
|
224
|
-
// interfaces, the response always contains it — no fallback is needed.
|
|
225
|
-
// This prevents incorrect fallbacks like ?? '' on string|null fields
|
|
226
|
-
// and invalid enum fallbacks like ?? 'Pending'.
|
|
227
|
-
const responseFieldInfo = baselineResponse?.fields?.[wire];
|
|
228
|
-
const responseFieldOptional = responseFieldInfo?.optional ?? false;
|
|
229
|
-
const needsFallback = responseFieldOptional || isNewField;
|
|
230
|
-
const fallback = needsFallback ? defaultForType(field.type) : null;
|
|
231
|
-
if (fallback) {
|
|
232
|
-
lines.push(` ${domain}: ${expr} ?? ${fallback},`);
|
|
233
|
-
} else {
|
|
234
|
-
lines.push(` ${domain}: ${expr},`);
|
|
235
|
-
}
|
|
236
|
-
} else {
|
|
237
|
-
lines.push(` ${domain}: ${expr},`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
// NOTE: Previously we added passthrough assignments for baseline-required fields
|
|
241
|
-
// missing from the IR model. This was removed because it creates type errors:
|
|
242
|
-
// the serializer would output fields that don't exist on the generated interface
|
|
243
|
-
// (e.g., Connection.type, AuditLogSchema.createdAt). If a baseline field is
|
|
244
|
-
// truly needed, the merger will preserve the existing serializer for that field.
|
|
245
|
-
lines.push('});');
|
|
246
|
-
|
|
247
|
-
// Serialize function (domain → wire) — skip when the baseline wire interface
|
|
248
|
-
// has required fields not covered by the IR model (the merger will preserve
|
|
249
|
-
// any existing hand-written serialize function).
|
|
250
|
-
if (!shouldSkipSerialize) {
|
|
251
|
-
const serParamPrefix = model.fields.length === 0 ? '_' : '';
|
|
252
|
-
lines.push('');
|
|
253
|
-
lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
|
|
254
|
-
lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
|
|
255
|
-
lines.push(`): ${responseName}${typeParams.usage} => ({`);
|
|
256
|
-
const seenSerFields = new Set<string>();
|
|
257
|
-
for (const field of model.fields) {
|
|
258
|
-
const wire = wireFieldName(field.name);
|
|
259
|
-
if (seenSerFields.has(wire)) continue;
|
|
260
|
-
seenSerFields.add(wire);
|
|
261
|
-
const domain = fieldName(field.name);
|
|
262
|
-
const domainAccess = `model.${domain}`;
|
|
263
|
-
const skip = skipFormatFields.has(field.name);
|
|
264
|
-
const expr = skip ? domainAccess : serializeExpression(field.type, domainAccess, ctx);
|
|
265
|
-
// Treat new fields (not in baseline) as effectively optional — see deserializer comment above.
|
|
266
|
-
const isNewSerField = baselineDomain && !baselineDomain.fields?.[domain];
|
|
267
|
-
const effectivelyOptionalSer = !field.required || isNewSerField;
|
|
268
|
-
|
|
269
|
-
// Check if the domain field is optional but the baseline wire field is required.
|
|
270
|
-
// The serializer assigns `T | undefined` to the wire field, but the wire interface
|
|
271
|
-
// expects `T`. Add `?? null` coalesce to strip undefined and satisfy the wire type.
|
|
272
|
-
const baselineWireField = baselineResponse?.fields?.[wire];
|
|
273
|
-
const baselineDomainField = baselineDomain?.fields?.[domain];
|
|
274
|
-
// The domain field is optional if: (a) the IR says it's optional, (b) the baseline says it's optional,
|
|
275
|
-
// or (c) the baseline domain exists but doesn't have this field name (it's a "new field on existing model"
|
|
276
|
-
// and the generated interface makes it optional). Case (c) covers renamed fields (e.g., baseline
|
|
277
|
-
// uses `type` but the generated interface uses `connectionType`).
|
|
278
|
-
const isNewFieldOnExistingDomain = baselineDomain && !baselineDomainField;
|
|
279
|
-
const domainFieldIsOptional =
|
|
280
|
-
!field.required || (baselineDomainField?.optional ?? false) || !!isNewFieldOnExistingDomain;
|
|
281
|
-
const wireFieldIsRequired = baselineWireField ? !baselineWireField.optional : field.required;
|
|
282
|
-
const needsUndefinedCoalesce = domainFieldIsOptional && wireFieldIsRequired && expr === domainAccess;
|
|
283
|
-
|
|
284
|
-
// If the expression involves a function call (nested model/array serializer),
|
|
285
|
-
// wrap with a null check to prevent crashes when callers pass partial objects
|
|
286
|
-
// (e.g., `{} as any` in tests).
|
|
287
|
-
// When the field type is nullable, preserve null instead of undefined.
|
|
288
|
-
// Guard nullable and optional nested model/array-of-model fields.
|
|
289
|
-
// Required non-nullable fields are not guarded — the caller must provide them.
|
|
290
|
-
const shouldGuardSer = effectivelyOptionalSer || field.type.kind === 'nullable';
|
|
291
|
-
if (expr !== domainAccess && needsNullGuard(field.type) && shouldGuardSer) {
|
|
292
|
-
// For nullable fields, fallback to null. For optional fields, fallback to undefined.
|
|
293
|
-
const fallback = field.type.kind === 'nullable' ? 'null' : 'undefined';
|
|
294
|
-
if (expr.startsWith(`${domainAccess} != null ?`)) {
|
|
295
|
-
lines.push(` ${wire}: ${expr.replace(/: null$/, `: ${fallback}`)},`);
|
|
296
|
-
} else {
|
|
297
|
-
lines.push(` ${wire}: ${domainAccess} != null ? ${expr} : ${fallback},`);
|
|
298
|
-
}
|
|
299
|
-
} else if (needsUndefinedCoalesce) {
|
|
300
|
-
// Domain field is optional (T | undefined) but wire field is required (T or T | null).
|
|
301
|
-
// When the wire type includes null, coalesce undefined → null.
|
|
302
|
-
// Otherwise, use a non-null assertion — the consumer is responsible for
|
|
303
|
-
// providing required fields when calling serialize, so undefined at runtime
|
|
304
|
-
// indicates a programming error on the caller's side.
|
|
305
|
-
const wireHasNull = baselineWireField?.type?.includes('null') || field.type.kind === 'nullable';
|
|
306
|
-
if (wireHasNull) {
|
|
307
|
-
lines.push(` ${wire}: ${expr} ?? null,`);
|
|
308
|
-
} else {
|
|
309
|
-
lines.push(` ${wire}: ${expr}!,`);
|
|
310
|
-
}
|
|
311
|
-
} else if (field.type.kind === 'nullable' && expr === domainAccess) {
|
|
312
|
-
// Check if the domain interface makes this field optional (T | null | undefined).
|
|
313
|
-
// This can happen when: (a) the IR says not required, (b) the field is new on
|
|
314
|
-
// an existing model, or (c) the baseline domain is required but the baseline
|
|
315
|
-
// response is optional (domainResponseOptionalMismatch in models.ts).
|
|
316
|
-
// In all these cases, the domain type includes `undefined` but the wire type
|
|
317
|
-
// may only accept `T | null`, so coalesce undefined → null.
|
|
318
|
-
const domainWireField2 = wireFieldName(field.name);
|
|
319
|
-
const responseBaselineField2 = baselineResponse?.fields?.[domainWireField2];
|
|
320
|
-
const baselineDomainField2 = baselineDomain?.fields?.[domain];
|
|
321
|
-
const domainResponseMismatch =
|
|
322
|
-
baselineDomainField2 &&
|
|
323
|
-
!baselineDomainField2.optional &&
|
|
324
|
-
responseBaselineField2 &&
|
|
325
|
-
responseBaselineField2.optional;
|
|
326
|
-
const fieldEffectivelyOptional = !field.required || isNewSerField || !!domainResponseMismatch;
|
|
327
|
-
if (fieldEffectivelyOptional) {
|
|
328
|
-
lines.push(` ${wire}: ${expr} ?? null,`);
|
|
329
|
-
} else {
|
|
330
|
-
lines.push(` ${wire}: ${expr},`);
|
|
331
|
-
}
|
|
332
|
-
} else {
|
|
333
|
-
lines.push(` ${wire}: ${expr},`);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
lines.push('});');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
files.push({
|
|
340
|
-
path: serializerPath,
|
|
341
|
-
content: pruneUnusedImports(lines).join('\n'),
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return files;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Collect model names that will actually be called in serialize/deserialize expressions.
|
|
350
|
-
* Unlike collectModelRefs (which walks all union variants), this only includes models
|
|
351
|
-
* that the expression functions will actually invoke a serializer/deserializer for.
|
|
352
|
-
*/
|
|
353
|
-
function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
354
|
-
switch (ref.kind) {
|
|
355
|
-
case 'model':
|
|
356
|
-
return [ref.name];
|
|
357
|
-
case 'array':
|
|
358
|
-
if (ref.items.kind === 'model') return [ref.items.name];
|
|
359
|
-
return collectSerializedModelRefs(ref.items);
|
|
360
|
-
case 'nullable':
|
|
361
|
-
return collectSerializedModelRefs(ref.inner);
|
|
362
|
-
case 'union': {
|
|
363
|
-
const models = uniqueModelVariants(ref);
|
|
364
|
-
// Discriminated unions and allOf unions need serializers for all model variants
|
|
365
|
-
if (ref.discriminator && models.length > 0) return models;
|
|
366
|
-
if (ref.compositionKind === 'allOf' && models.length > 0) return models;
|
|
367
|
-
// Only if exactly one unique model variant — that's when we call its serializer
|
|
368
|
-
if (models.length === 1) return models;
|
|
369
|
-
return [];
|
|
370
|
-
}
|
|
371
|
-
case 'map':
|
|
372
|
-
case 'primitive':
|
|
373
|
-
case 'literal':
|
|
374
|
-
case 'enum':
|
|
375
|
-
return [];
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterContext): string {
|
|
380
|
-
switch (ref.kind) {
|
|
381
|
-
case 'primitive':
|
|
382
|
-
return deserializePrimitive(ref, wireExpr);
|
|
383
|
-
case 'literal':
|
|
384
|
-
case 'enum':
|
|
385
|
-
return wireExpr;
|
|
386
|
-
case 'model': {
|
|
387
|
-
const name = resolveInterfaceName(ref.name, ctx);
|
|
388
|
-
return `deserialize${name}(${wireExpr})`;
|
|
389
|
-
}
|
|
390
|
-
case 'array':
|
|
391
|
-
if (ref.items.kind === 'model') {
|
|
392
|
-
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
393
|
-
return `${wireExpr}.map(deserialize${name})`;
|
|
394
|
-
}
|
|
395
|
-
return wireExpr;
|
|
396
|
-
case 'nullable': {
|
|
397
|
-
const innerExpr = deserializeExpression(ref.inner, wireExpr, ctx);
|
|
398
|
-
// If the inner type involves a function call (model or array-of-model),
|
|
399
|
-
// wrap with a null check to avoid passing null to the deserializer
|
|
400
|
-
if (innerExpr !== wireExpr) {
|
|
401
|
-
return `${wireExpr} != null ? ${innerExpr} : null`;
|
|
402
|
-
}
|
|
403
|
-
return `${wireExpr} ?? null`;
|
|
404
|
-
}
|
|
405
|
-
case 'union': {
|
|
406
|
-
// Discriminated union: switch on the discriminator property
|
|
407
|
-
if (ref.discriminator) {
|
|
408
|
-
return renderDiscriminatorSwitch(ref, wireExpr, 'deserialize', ctx);
|
|
409
|
-
}
|
|
410
|
-
// allOf union: merge all model variant fields via spread
|
|
411
|
-
if (ref.compositionKind === 'allOf') {
|
|
412
|
-
return renderAllOfMerge(ref, wireExpr, 'deserialize', ctx);
|
|
413
|
-
}
|
|
414
|
-
// If the union has exactly one unique model variant, deserialize using that model's deserializer
|
|
415
|
-
const deserModelVariants = uniqueModelVariants(ref);
|
|
416
|
-
if (deserModelVariants.length === 1) {
|
|
417
|
-
const name = resolveInterfaceName(deserModelVariants[0], ctx);
|
|
418
|
-
return `deserialize${name}(${wireExpr})`;
|
|
419
|
-
}
|
|
420
|
-
return wireExpr;
|
|
421
|
-
}
|
|
422
|
-
case 'map':
|
|
423
|
-
return wireExpr;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterContext): string {
|
|
428
|
-
switch (ref.kind) {
|
|
429
|
-
case 'primitive':
|
|
430
|
-
return serializePrimitive(ref, domainExpr);
|
|
431
|
-
case 'literal':
|
|
432
|
-
case 'enum':
|
|
433
|
-
return domainExpr;
|
|
434
|
-
case 'model': {
|
|
435
|
-
const name = resolveInterfaceName(ref.name, ctx);
|
|
436
|
-
return `serialize${name}(${domainExpr})`;
|
|
437
|
-
}
|
|
438
|
-
case 'array':
|
|
439
|
-
if (ref.items.kind === 'model') {
|
|
440
|
-
const name = resolveInterfaceName(ref.items.name, ctx);
|
|
441
|
-
return `${domainExpr}.map(serialize${name})`;
|
|
442
|
-
}
|
|
443
|
-
return domainExpr;
|
|
444
|
-
case 'nullable': {
|
|
445
|
-
const innerExpr = serializeExpression(ref.inner, domainExpr, ctx);
|
|
446
|
-
// If the inner type involves a function call (model or array-of-model),
|
|
447
|
-
// wrap with a null check to avoid passing null to the serializer
|
|
448
|
-
if (innerExpr !== domainExpr) {
|
|
449
|
-
return `${domainExpr} != null ? ${innerExpr} : null`;
|
|
450
|
-
}
|
|
451
|
-
return domainExpr;
|
|
452
|
-
}
|
|
453
|
-
case 'union': {
|
|
454
|
-
// Discriminated union: switch on the discriminator property
|
|
455
|
-
if (ref.discriminator) {
|
|
456
|
-
return renderDiscriminatorSwitch(ref, domainExpr, 'serialize', ctx);
|
|
457
|
-
}
|
|
458
|
-
// allOf union: merge all model variant fields via spread
|
|
459
|
-
if (ref.compositionKind === 'allOf') {
|
|
460
|
-
return renderAllOfMerge(ref, domainExpr, 'serialize', ctx);
|
|
461
|
-
}
|
|
462
|
-
// If the union has exactly one unique model variant, serialize using that model's serializer
|
|
463
|
-
const serModelVariants = uniqueModelVariants(ref);
|
|
464
|
-
if (serModelVariants.length === 1) {
|
|
465
|
-
const name = resolveInterfaceName(serModelVariants[0], ctx);
|
|
466
|
-
return `serialize${name}(${domainExpr})`;
|
|
467
|
-
}
|
|
468
|
-
return domainExpr;
|
|
469
|
-
}
|
|
470
|
-
case 'map':
|
|
471
|
-
return domainExpr;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Extract unique model names from a union's variants.
|
|
477
|
-
* Used to determine if a union can be deserialized/serialized as a single model.
|
|
478
|
-
*/
|
|
479
|
-
function uniqueModelVariants(ref: UnionType): string[] {
|
|
480
|
-
const modelNames = new Set<string>();
|
|
481
|
-
for (const v of ref.variants) {
|
|
482
|
-
if (v.kind === 'model') modelNames.add(v.name);
|
|
483
|
-
}
|
|
484
|
-
return [...modelNames];
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Check whether a TypeRef involves a model reference or format conversion
|
|
489
|
-
* that would produce a function call in serialization/deserialization.
|
|
490
|
-
* Used to determine whether optional fields need a null guard wrapper.
|
|
491
|
-
*/
|
|
492
|
-
function needsNullGuard(ref: TypeRef): boolean {
|
|
493
|
-
switch (ref.kind) {
|
|
494
|
-
case 'model':
|
|
495
|
-
return true;
|
|
496
|
-
case 'primitive':
|
|
497
|
-
return hasFormatConversion(ref);
|
|
498
|
-
case 'array':
|
|
499
|
-
return ref.items.kind === 'model';
|
|
500
|
-
case 'nullable':
|
|
501
|
-
return needsNullGuard(ref.inner);
|
|
502
|
-
case 'union':
|
|
503
|
-
if (ref.discriminator) return true;
|
|
504
|
-
if (ref.compositionKind === 'allOf' && uniqueModelVariants(ref).length > 0) return true;
|
|
505
|
-
return uniqueModelVariants(ref).length === 1;
|
|
506
|
-
default:
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** Check if a type has a format that requires conversion. */
|
|
512
|
-
function hasFormatConversion(ref: TypeRef): boolean {
|
|
513
|
-
switch (ref.kind) {
|
|
514
|
-
case 'primitive':
|
|
515
|
-
return ref.format === 'date-time' || ref.format === 'int64';
|
|
516
|
-
case 'nullable':
|
|
517
|
-
return hasFormatConversion(ref.inner);
|
|
518
|
-
default:
|
|
519
|
-
return false;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** Check if a type specifically has a date-time format conversion. */
|
|
524
|
-
function hasDateTimeConversion(ref: TypeRef): boolean {
|
|
525
|
-
switch (ref.kind) {
|
|
526
|
-
case 'primitive':
|
|
527
|
-
return ref.format === 'date-time';
|
|
528
|
-
case 'nullable':
|
|
529
|
-
return hasDateTimeConversion(ref.inner);
|
|
530
|
-
default:
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/** Deserialize a primitive value, applying format conversions when needed. */
|
|
536
|
-
function deserializePrimitive(ref: PrimitiveType, wireExpr: string): string {
|
|
537
|
-
if (ref.format === 'date-time') return `new Date(${wireExpr})`;
|
|
538
|
-
if (ref.format === 'int64') return `BigInt(${wireExpr})`;
|
|
539
|
-
return wireExpr;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** Serialize a primitive value, applying format conversions when needed. */
|
|
543
|
-
function serializePrimitive(ref: PrimitiveType, domainExpr: string): string {
|
|
544
|
-
if (ref.format === 'date-time') return `${domainExpr}.toISOString()`;
|
|
545
|
-
if (ref.format === 'int64') return `String(${domainExpr})`;
|
|
546
|
-
return domainExpr;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Render a discriminated union switch expression.
|
|
551
|
-
* Produces an IIFE that switches on the discriminator property and calls
|
|
552
|
-
* the appropriate serializer/deserializer for each mapped model.
|
|
553
|
-
*/
|
|
554
|
-
function renderDiscriminatorSwitch(
|
|
555
|
-
ref: UnionType,
|
|
556
|
-
expr: string,
|
|
557
|
-
direction: 'deserialize' | 'serialize',
|
|
558
|
-
ctx: EmitterContext,
|
|
559
|
-
): string {
|
|
560
|
-
const disc = ref.discriminator!;
|
|
561
|
-
const cases: string[] = [];
|
|
562
|
-
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
563
|
-
const resolved = resolveInterfaceName(modelName, ctx);
|
|
564
|
-
const fn = `${direction}${resolved}`;
|
|
565
|
-
cases.push(`case '${value}': return ${fn}(${expr} as any)`);
|
|
566
|
-
}
|
|
567
|
-
return `(() => { switch ((${expr} as any).${disc.property}) { ${cases.join('; ')}; default: return ${expr} } })()`;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Render an allOf merge expression.
|
|
572
|
-
* Spreads the serialized/deserialized result of each model variant.
|
|
573
|
-
*/
|
|
574
|
-
function renderAllOfMerge(
|
|
575
|
-
ref: UnionType,
|
|
576
|
-
expr: string,
|
|
577
|
-
direction: 'deserialize' | 'serialize',
|
|
578
|
-
ctx: EmitterContext,
|
|
579
|
-
): string {
|
|
580
|
-
const models = uniqueModelVariants(ref);
|
|
581
|
-
if (models.length === 0) return expr;
|
|
582
|
-
const spreads = models.map((name) => {
|
|
583
|
-
const resolved = resolveInterfaceName(name, ctx);
|
|
584
|
-
return `...${direction}${resolved}(${expr} as any)`;
|
|
585
|
-
});
|
|
586
|
-
return `({ ${spreads.join(', ')} })`;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Return a TypeScript default value expression for a type, used as a null
|
|
591
|
-
* coalesce fallback when a required domain field may be optional in the
|
|
592
|
-
* response interface (baseline override mismatch).
|
|
593
|
-
*/
|
|
594
|
-
function defaultForType(ref: TypeRef): string | null {
|
|
595
|
-
switch (ref.kind) {
|
|
596
|
-
case 'literal':
|
|
597
|
-
// Use the literal value itself as the fallback (e.g., 'role' for object: 'role')
|
|
598
|
-
return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
|
|
599
|
-
case 'enum':
|
|
600
|
-
// Don't provide enum fallbacks — the first enum value may not be a valid
|
|
601
|
-
// member of the target type (e.g., 'Pending' is not a member of ConnectionType).
|
|
602
|
-
// If the field is required, the API always sends it; if the response baseline
|
|
603
|
-
// says optional, null/undefined is safer than guessing a value.
|
|
604
|
-
return null;
|
|
605
|
-
case 'map':
|
|
606
|
-
return '{}';
|
|
607
|
-
case 'nullable':
|
|
608
|
-
// Nullable fields should fall back to null, not the inner type's default.
|
|
609
|
-
// This prevents incorrect conversions like nullable<string> → '' instead of null.
|
|
610
|
-
return 'null';
|
|
611
|
-
case 'primitive':
|
|
612
|
-
switch (ref.type) {
|
|
613
|
-
case 'boolean':
|
|
614
|
-
return 'false';
|
|
615
|
-
case 'string':
|
|
616
|
-
return "''";
|
|
617
|
-
case 'integer':
|
|
618
|
-
case 'number':
|
|
619
|
-
return '0';
|
|
620
|
-
default:
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
case 'array':
|
|
624
|
-
return '[]';
|
|
625
|
-
default:
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Check if the generated serialize function would produce type errors
|
|
632
|
-
* against the baseline wire (response) interface. Returns true when:
|
|
633
|
-
* - The baseline response interface has required fields whose wire name
|
|
634
|
-
* doesn't match any IR model field → TS2741 missing property.
|
|
635
|
-
* - The baseline domain interface has required fields whose camelCase name
|
|
636
|
-
* doesn't match any IR model field → the serializer would produce
|
|
637
|
-
* expressions referencing domain fields that don't exist on the baseline.
|
|
638
|
-
* - The baseline response has a required array field whose type references
|
|
639
|
-
* a different module than where the serializer imports its nested serializer.
|
|
640
|
-
*/
|
|
641
|
-
function serializerHasBaselineIncompatibility(
|
|
642
|
-
model: Model,
|
|
643
|
-
baselineResponse: { fields?: Record<string, { type: string; optional: boolean }> } | undefined,
|
|
644
|
-
baselineDomain?: {
|
|
645
|
-
fields?: Record<string, { type: string; optional: boolean }>;
|
|
646
|
-
},
|
|
647
|
-
ctx?: EmitterContext,
|
|
648
|
-
): boolean {
|
|
649
|
-
if (!baselineResponse?.fields) return false;
|
|
650
|
-
|
|
651
|
-
// Collect all wire-format field names the IR model will produce
|
|
652
|
-
const irWireFields = new Set<string>();
|
|
653
|
-
const irDomainFields = new Set<string>();
|
|
654
|
-
for (const field of model.fields) {
|
|
655
|
-
irWireFields.add(wireFieldName(field.name));
|
|
656
|
-
irDomainFields.add(fieldName(field.name));
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Check if the baseline response has required fields not in the IR model
|
|
660
|
-
for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
|
|
661
|
-
if (fieldDef.optional) continue; // Optional fields won't cause TS errors
|
|
662
|
-
if (!irWireFields.has(wireField2)) {
|
|
663
|
-
// Baseline requires a field the IR doesn't produce → type error
|
|
664
|
-
return true;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Check if the baseline domain has required fields whose names differ from
|
|
669
|
-
// what the IR would generate (e.g., baseline uses `type` but IR maps
|
|
670
|
-
// `connection_type` → `connectionType`). If there are required fields in
|
|
671
|
-
// the baseline domain that the IR doesn't produce, the serializer would read
|
|
672
|
-
// from domain fields that may have incompatible types.
|
|
673
|
-
if (baselineDomain?.fields) {
|
|
674
|
-
const baselineRequiredFields = Object.entries(baselineDomain.fields)
|
|
675
|
-
.filter(([, f]) => !f.optional)
|
|
676
|
-
.map(([name]) => name);
|
|
677
|
-
// Count how many baseline required fields are NOT in the IR domain fields.
|
|
678
|
-
// If more than 1/3 of required fields are unrecognized, assume significant
|
|
679
|
-
// structural differences → skip serialize to avoid type mismatches.
|
|
680
|
-
const unmatchedCount = baselineRequiredFields.filter((n) => !irDomainFields.has(n)).length;
|
|
681
|
-
if (unmatchedCount > 0 && baselineRequiredFields.length > 0) {
|
|
682
|
-
const unmatchedRatio = unmatchedCount / baselineRequiredFields.length;
|
|
683
|
-
if (unmatchedRatio > 0.3) {
|
|
684
|
-
return true;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Check for nested model type mismatches: when the baseline response interface
|
|
690
|
-
// references a nested model type whose source file is in a DIFFERENT directory
|
|
691
|
-
// than the serializer's parent directory. The generated serializer creates local
|
|
692
|
-
// copies of nested model interfaces (via the model generator), and these local
|
|
693
|
-
// copies are structurally similar but TypeScript treats them as different types.
|
|
694
|
-
// Example: OrganizationDomainResponse from organization-domains/interfaces/ vs
|
|
695
|
-
// the generated copy in organizations/interfaces/ — same structure, different modules.
|
|
696
|
-
if (ctx?.apiSurface?.interfaces) {
|
|
697
|
-
// Determine the serializer's parent directory from the model name
|
|
698
|
-
const modelSourceFile = (baselineResponse as any)?.sourceFile as string | undefined;
|
|
699
|
-
const responseDir = modelSourceFile ? modelSourceFile.split('/').slice(0, 2).join('/') : null;
|
|
700
|
-
|
|
701
|
-
for (const field of model.fields) {
|
|
702
|
-
// Unwrap nullable to get the inner model type
|
|
703
|
-
let fieldType = field.type;
|
|
704
|
-
if (fieldType.kind === 'nullable') fieldType = fieldType.inner;
|
|
705
|
-
if (fieldType.kind !== 'array' && fieldType.kind !== 'model') continue;
|
|
706
|
-
const innerType = fieldType.kind === 'array' ? fieldType.items : fieldType;
|
|
707
|
-
if (innerType.kind !== 'model') continue;
|
|
708
|
-
|
|
709
|
-
const nestedWireName = wireInterfaceName(resolveInterfaceName(innerType.name, ctx));
|
|
710
|
-
const wireField3 = wireFieldName(field.name);
|
|
711
|
-
const baselineWireField2 = baselineResponse.fields[wireField3];
|
|
712
|
-
if (!baselineWireField2) continue;
|
|
713
|
-
|
|
714
|
-
// Check for type name mismatch: the baseline wire field references a type
|
|
715
|
-
// that is different from what the generated serializer would produce.
|
|
716
|
-
// e.g., baseline has `role: RoleResponse` but the deduped serializer returns
|
|
717
|
-
// `AddRolePermissionResponse`.
|
|
718
|
-
const baselineTypeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*Response\b/g) || [];
|
|
719
|
-
if (baselineTypeNames.length > 0 && !baselineTypeNames.includes(nestedWireName)) {
|
|
720
|
-
// The baseline expects a different Response type than the serializer produces
|
|
721
|
-
return true;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Check if the baseline wire field type includes the nested wire type name
|
|
725
|
-
if (baselineWireField2.type.includes(nestedWireName) || baselineWireField2.type.match(/\b[A-Z]\w*Response\b/)) {
|
|
726
|
-
// Extract type names from the baseline field type
|
|
727
|
-
const typeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
|
|
728
|
-
for (const typeName of typeNames) {
|
|
729
|
-
if (typeName === 'Record' || typeName === 'Array') continue;
|
|
730
|
-
const nestedIface = ctx.apiSurface.interfaces[typeName];
|
|
731
|
-
if (!nestedIface) continue;
|
|
732
|
-
const nestedSrc = (nestedIface as any).sourceFile as string | undefined;
|
|
733
|
-
if (!nestedSrc || !responseDir) continue;
|
|
734
|
-
const nestedDir = nestedSrc.split('/').slice(0, 2).join('/');
|
|
735
|
-
if (nestedDir !== responseDir) {
|
|
736
|
-
// The baseline response uses a type from a different directory than
|
|
737
|
-
// where the response itself lives → cross-module type incompatibility
|
|
738
|
-
return true;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
return false;
|
|
746
|
-
}
|