@workos/oagen-emitters 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/serializers.ts
CHANGED
|
@@ -1,30 +1,157 @@
|
|
|
1
|
-
import type { Model, EmitterContext, GeneratedFile, TypeRef, UnionType } from '@workos/oagen';
|
|
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';
|
|
2
4
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
}
|
|
12
53
|
|
|
13
54
|
export function generateSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
14
55
|
if (models.length === 0) return [];
|
|
15
56
|
|
|
16
|
-
const modelToService =
|
|
17
|
-
const
|
|
18
|
-
const resolveDir = (irService: string | undefined) =>
|
|
19
|
-
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
57
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
58
|
+
const useStringDates = detectStringDateConvention(models, ctx);
|
|
20
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>();
|
|
21
64
|
|
|
22
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
|
+
|
|
23
94
|
const service = modelToService.get(model.name);
|
|
24
95
|
const dirName = resolveDir(service);
|
|
25
96
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
26
97
|
const responseName = wireInterfaceName(domainName);
|
|
27
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
|
+
}
|
|
28
155
|
|
|
29
156
|
// Find nested model refs that need their own serializer imports.
|
|
30
157
|
// Only collect models that will actually be called in serialize/deserialize expressions
|
|
@@ -44,37 +171,63 @@ export function generateSerializers(models: Model[], ctx: EmitterContext): Gener
|
|
|
44
171
|
`import type { ${domainName}, ${responseName} } from '${relativeImport(serializerPath, interfacePath)}';`,
|
|
45
172
|
);
|
|
46
173
|
|
|
47
|
-
// Import nested model deserializers/serializers
|
|
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).
|
|
48
177
|
for (const dep of nestedModelRefs) {
|
|
49
178
|
const depService = modelToService.get(dep);
|
|
50
179
|
const depDir = resolveDir(depService);
|
|
51
180
|
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
52
181
|
const depName = resolveInterfaceName(dep, ctx);
|
|
53
|
-
const
|
|
54
|
-
lines.push(`import { ${
|
|
182
|
+
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
183
|
+
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
55
184
|
}
|
|
56
185
|
lines.push('');
|
|
57
186
|
|
|
58
187
|
// Deserialize function (wire → domain) — deduplicate by camelCase name
|
|
59
188
|
const seenDeserFields = new Set<string>();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
lines.push(`
|
|
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} => ({`);
|
|
63
194
|
for (const field of model.fields) {
|
|
64
195
|
const domain = fieldName(field.name);
|
|
65
196
|
if (seenDeserFields.has(domain)) continue;
|
|
66
197
|
seenDeserFields.add(domain);
|
|
67
198
|
const wire = wireFieldName(field.name);
|
|
68
199
|
const wireAccess = `response.${wire}`;
|
|
69
|
-
const
|
|
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;
|
|
70
207
|
// If the field is optional and the expression involves a function call,
|
|
71
|
-
// wrap with a null check to avoid passing undefined to the deserializer
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
|
74
220
|
} else if (field.required && expr === wireAccess) {
|
|
75
|
-
// Required field with direct assignment — add fallback
|
|
76
|
-
//
|
|
77
|
-
|
|
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;
|
|
78
231
|
if (fallback) {
|
|
79
232
|
lines.push(` ${domain}: ${expr} ?? ${fallback},`);
|
|
80
233
|
} else {
|
|
@@ -84,35 +237,108 @@ export function generateSerializers(models: Model[], ctx: EmitterContext): Gener
|
|
|
84
237
|
lines.push(` ${domain}: ${expr},`);
|
|
85
238
|
}
|
|
86
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.
|
|
87
245
|
lines.push('});');
|
|
88
246
|
|
|
89
|
-
// Serialize function (domain → wire)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
seenSerFields
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
|
108
335
|
}
|
|
336
|
+
lines.push('});');
|
|
109
337
|
}
|
|
110
|
-
lines.push('});');
|
|
111
338
|
|
|
112
339
|
files.push({
|
|
113
340
|
path: serializerPath,
|
|
114
|
-
content: lines.join('\n'),
|
|
115
|
-
skipIfExists: true,
|
|
341
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
116
342
|
});
|
|
117
343
|
}
|
|
118
344
|
|
|
@@ -135,6 +361,9 @@ function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
|
135
361
|
return collectSerializedModelRefs(ref.inner);
|
|
136
362
|
case 'union': {
|
|
137
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;
|
|
138
367
|
// Only if exactly one unique model variant — that's when we call its serializer
|
|
139
368
|
if (models.length === 1) return models;
|
|
140
369
|
return [];
|
|
@@ -150,6 +379,7 @@ function collectSerializedModelRefs(ref: TypeRef): string[] {
|
|
|
150
379
|
function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterContext): string {
|
|
151
380
|
switch (ref.kind) {
|
|
152
381
|
case 'primitive':
|
|
382
|
+
return deserializePrimitive(ref, wireExpr);
|
|
153
383
|
case 'literal':
|
|
154
384
|
case 'enum':
|
|
155
385
|
return wireExpr;
|
|
@@ -173,6 +403,14 @@ function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterConte
|
|
|
173
403
|
return `${wireExpr} ?? null`;
|
|
174
404
|
}
|
|
175
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
|
+
}
|
|
176
414
|
// If the union has exactly one unique model variant, deserialize using that model's deserializer
|
|
177
415
|
const deserModelVariants = uniqueModelVariants(ref);
|
|
178
416
|
if (deserModelVariants.length === 1) {
|
|
@@ -189,6 +427,7 @@ function deserializeExpression(ref: TypeRef, wireExpr: string, ctx: EmitterConte
|
|
|
189
427
|
function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterContext): string {
|
|
190
428
|
switch (ref.kind) {
|
|
191
429
|
case 'primitive':
|
|
430
|
+
return serializePrimitive(ref, domainExpr);
|
|
192
431
|
case 'literal':
|
|
193
432
|
case 'enum':
|
|
194
433
|
return domainExpr;
|
|
@@ -212,6 +451,14 @@ function serializeExpression(ref: TypeRef, domainExpr: string, ctx: EmitterConte
|
|
|
212
451
|
return domainExpr;
|
|
213
452
|
}
|
|
214
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
|
+
}
|
|
215
462
|
// If the union has exactly one unique model variant, serialize using that model's serializer
|
|
216
463
|
const serModelVariants = uniqueModelVariants(ref);
|
|
217
464
|
if (serModelVariants.length === 1) {
|
|
@@ -238,25 +485,107 @@ function uniqueModelVariants(ref: UnionType): string[] {
|
|
|
238
485
|
}
|
|
239
486
|
|
|
240
487
|
/**
|
|
241
|
-
* Check whether a TypeRef involves a model reference
|
|
242
|
-
* a function call in serialization/deserialization.
|
|
243
|
-
* whether optional fields need a null guard wrapper.
|
|
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.
|
|
244
491
|
*/
|
|
245
492
|
function needsNullGuard(ref: TypeRef): boolean {
|
|
246
493
|
switch (ref.kind) {
|
|
247
494
|
case 'model':
|
|
248
495
|
return true;
|
|
496
|
+
case 'primitive':
|
|
497
|
+
return hasFormatConversion(ref);
|
|
249
498
|
case 'array':
|
|
250
499
|
return ref.items.kind === 'model';
|
|
251
500
|
case 'nullable':
|
|
252
501
|
return needsNullGuard(ref.inner);
|
|
253
502
|
case 'union':
|
|
503
|
+
if (ref.discriminator) return true;
|
|
504
|
+
if (ref.compositionKind === 'allOf' && uniqueModelVariants(ref).length > 0) return true;
|
|
254
505
|
return uniqueModelVariants(ref).length === 1;
|
|
255
506
|
default:
|
|
256
507
|
return false;
|
|
257
508
|
}
|
|
258
509
|
}
|
|
259
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
|
+
|
|
260
589
|
/**
|
|
261
590
|
* Return a TypeScript default value expression for a type, used as a null
|
|
262
591
|
* coalesce fallback when a required domain field may be optional in the
|
|
@@ -264,8 +593,21 @@ function needsNullGuard(ref: TypeRef): boolean {
|
|
|
264
593
|
*/
|
|
265
594
|
function defaultForType(ref: TypeRef): string | null {
|
|
266
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;
|
|
267
605
|
case 'map':
|
|
268
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';
|
|
269
611
|
case 'primitive':
|
|
270
612
|
switch (ref.type) {
|
|
271
613
|
case 'boolean':
|
|
@@ -284,3 +626,119 @@ function defaultForType(ref: TypeRef): string | null {
|
|
|
284
626
|
return null;
|
|
285
627
|
}
|
|
286
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?: { fields?: Record<string, { type: string; optional: boolean }> },
|
|
645
|
+
ctx?: EmitterContext,
|
|
646
|
+
): boolean {
|
|
647
|
+
if (!baselineResponse?.fields) return false;
|
|
648
|
+
|
|
649
|
+
// Collect all wire-format field names the IR model will produce
|
|
650
|
+
const irWireFields = new Set<string>();
|
|
651
|
+
const irDomainFields = new Set<string>();
|
|
652
|
+
for (const field of model.fields) {
|
|
653
|
+
irWireFields.add(wireFieldName(field.name));
|
|
654
|
+
irDomainFields.add(fieldName(field.name));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Check if the baseline response has required fields not in the IR model
|
|
658
|
+
for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
|
|
659
|
+
if (fieldDef.optional) continue; // Optional fields won't cause TS errors
|
|
660
|
+
if (!irWireFields.has(wireField2)) {
|
|
661
|
+
// Baseline requires a field the IR doesn't produce → type error
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check if the baseline domain has required fields whose names differ from
|
|
667
|
+
// what the IR would generate (e.g., baseline uses `type` but IR maps
|
|
668
|
+
// `connection_type` → `connectionType`). If there are required fields in
|
|
669
|
+
// the baseline domain that the IR doesn't produce, the serializer would read
|
|
670
|
+
// from domain fields that may have incompatible types.
|
|
671
|
+
if (baselineDomain?.fields) {
|
|
672
|
+
const baselineRequiredFields = Object.entries(baselineDomain.fields)
|
|
673
|
+
.filter(([, f]) => !f.optional)
|
|
674
|
+
.map(([name]) => name);
|
|
675
|
+
// Count how many baseline required fields are NOT in the IR domain fields.
|
|
676
|
+
// If more than 1/3 of required fields are unrecognized, assume significant
|
|
677
|
+
// structural differences → skip serialize to avoid type mismatches.
|
|
678
|
+
const unmatchedCount = baselineRequiredFields.filter((n) => !irDomainFields.has(n)).length;
|
|
679
|
+
if (unmatchedCount > 0 && baselineRequiredFields.length > 0) {
|
|
680
|
+
const unmatchedRatio = unmatchedCount / baselineRequiredFields.length;
|
|
681
|
+
if (unmatchedRatio > 0.3) {
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Check for nested model type mismatches: when the baseline response interface
|
|
688
|
+
// references a nested model type whose source file is in a DIFFERENT directory
|
|
689
|
+
// than the serializer's parent directory. The generated serializer creates local
|
|
690
|
+
// copies of nested model interfaces (via the model generator), and these local
|
|
691
|
+
// copies are structurally similar but TypeScript treats them as different types.
|
|
692
|
+
// Example: OrganizationDomainResponse from organization-domains/interfaces/ vs
|
|
693
|
+
// the generated copy in organizations/interfaces/ — same structure, different modules.
|
|
694
|
+
if (ctx?.apiSurface?.interfaces) {
|
|
695
|
+
// Determine the serializer's parent directory from the model name
|
|
696
|
+
const modelSourceFile = (baselineResponse as any)?.sourceFile as string | undefined;
|
|
697
|
+
const responseDir = modelSourceFile ? modelSourceFile.split('/').slice(0, 2).join('/') : null;
|
|
698
|
+
|
|
699
|
+
for (const field of model.fields) {
|
|
700
|
+
// Unwrap nullable to get the inner model type
|
|
701
|
+
let fieldType = field.type;
|
|
702
|
+
if (fieldType.kind === 'nullable') fieldType = fieldType.inner;
|
|
703
|
+
if (fieldType.kind !== 'array' && fieldType.kind !== 'model') continue;
|
|
704
|
+
const innerType = fieldType.kind === 'array' ? fieldType.items : fieldType;
|
|
705
|
+
if (innerType.kind !== 'model') continue;
|
|
706
|
+
|
|
707
|
+
const nestedWireName = wireInterfaceName(resolveInterfaceName(innerType.name, ctx));
|
|
708
|
+
const wireField3 = wireFieldName(field.name);
|
|
709
|
+
const baselineWireField2 = baselineResponse.fields[wireField3];
|
|
710
|
+
if (!baselineWireField2) continue;
|
|
711
|
+
|
|
712
|
+
// Check for type name mismatch: the baseline wire field references a type
|
|
713
|
+
// that is different from what the generated serializer would produce.
|
|
714
|
+
// e.g., baseline has `role: RoleResponse` but the deduped serializer returns
|
|
715
|
+
// `AddRolePermissionResponse`.
|
|
716
|
+
const baselineTypeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*Response\b/g) || [];
|
|
717
|
+
if (baselineTypeNames.length > 0 && !baselineTypeNames.includes(nestedWireName)) {
|
|
718
|
+
// The baseline expects a different Response type than the serializer produces
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Check if the baseline wire field type includes the nested wire type name
|
|
723
|
+
if (baselineWireField2.type.includes(nestedWireName) || baselineWireField2.type.match(/\b[A-Z]\w*Response\b/)) {
|
|
724
|
+
// Extract type names from the baseline field type
|
|
725
|
+
const typeNames: string[] = baselineWireField2.type.match(/\b[A-Z][a-zA-Z0-9]*\b/g) || [];
|
|
726
|
+
for (const typeName of typeNames) {
|
|
727
|
+
if (typeName === 'Record' || typeName === 'Array') continue;
|
|
728
|
+
const nestedIface = ctx.apiSurface.interfaces[typeName];
|
|
729
|
+
if (!nestedIface) continue;
|
|
730
|
+
const nestedSrc = (nestedIface as any).sourceFile as string | undefined;
|
|
731
|
+
if (!nestedSrc || !responseDir) continue;
|
|
732
|
+
const nestedDir = nestedSrc.split('/').slice(0, 2).join('/');
|
|
733
|
+
if (nestedDir !== responseDir) {
|
|
734
|
+
// The baseline response uses a type from a different directory than
|
|
735
|
+
// where the response itself lives → cross-module type incompatibility
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return false;
|
|
744
|
+
}
|