@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.
Files changed (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -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
- }