@contractkit/plugin-typescript 0.32.0 → 0.33.1

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.
@@ -0,0 +1,304 @@
1
+ import type { ContractTypeNode, FieldNode, ModelNode } from '@contractkit/core';
2
+
3
+ /**
4
+ * Emitters for the `reviveX` functions that rehydrate `decimal` fields in an SDK response.
5
+ *
6
+ * A decimal arrives as a quoted JSON string, but the generated types say `Decimal`, so something
7
+ * has to construct one. The SDK cannot do it the way `bigint` does — `bigIntReviver` works only
8
+ * because bigint invented a tagged `"123n"` wire encoding, and `"10.50"` is indistinguishable from
9
+ * an ordinary string without knowing the schema. Nor can it re-parse the response through the Zod
10
+ * schema: `XOutput` is a `z.output<>` type alias with no runtime value behind it, and models
11
+ * default to `z.strictObject`, so any field the server added would throw in every deployed client.
12
+ *
13
+ * So the knowledge lives in generated code instead: one function per model that walks to the field
14
+ * positions a decimal can occupy and converts in place. Mutating rather than rebuilding keeps the
15
+ * cost proportional to the number of decimal fields, and preserves unknown server-added keys —
16
+ * the forward compatibility a strict re-parse would destroy.
17
+ *
18
+ * Emitted from the AST, so zod mode and plain-types mode produce identical runtime behaviour.
19
+ */
20
+
21
+ export interface ReviveCodegenOptions {
22
+ /** Models that carry a decimal, directly or transitively. Only these get a reviver. */
23
+ modelsWithDecimal: Set<string>;
24
+ /** Models with an `Output` variant, which need a second reviver keyed by the output casing. */
25
+ modelsWithOutput?: Set<string>;
26
+ /** Every model in scope, for resolving discriminated-union members to their literal tag. */
27
+ modelMap?: Map<string, ModelNode>;
28
+ }
29
+
30
+ /** The per-file coercion helper. Emitted once in any file that declares a reviver. */
31
+ export const DECIMAL_COERCE_DECL = [
32
+ `const __dec = (v: unknown, path: string): Decimal => {`,
33
+ ` if (typeof v !== 'string') {`,
34
+ ` throw new TypeError(\`ContractKit: expected a decimal string at '\${path}', received \${typeof v} — decimals must be sent as quoted JSON strings.\`);`,
35
+ ` }`,
36
+ ` try {`,
37
+ ` return new Decimal(v);`,
38
+ ` } catch {`,
39
+ ` throw new TypeError(\`ContractKit: '\${v}' at '\${path}' is not a valid decimal.\`);`,
40
+ ` }`,
41
+ `};`,
42
+ ];
43
+
44
+ /** `reviveInvoice` / `reviveInvoiceOutput`. */
45
+ export function reviveFnName(model: string, variant: 'base' | 'output' = 'base'): string {
46
+ return `revive${model}${variant === 'output' ? 'Output' : ''}`;
47
+ }
48
+
49
+ function applyCase(name: string, caseTransform: 'camel' | 'snake' | 'pascal' | undefined): string {
50
+ if (!caseTransform || caseTransform === 'camel') return name;
51
+ if (caseTransform === 'snake') return name.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
52
+ return name.charAt(0).toUpperCase() + name.slice(1);
53
+ }
54
+
55
+ /** Whether a type reaches a decimal, following refs through `modelsWithDecimal`. */
56
+ export function typeReachesDecimal(type: ContractTypeNode, opts: ReviveCodegenOptions): boolean {
57
+ switch (type.kind) {
58
+ case 'scalar':
59
+ return type.name === 'decimal';
60
+ case 'ref':
61
+ return opts.modelsWithDecimal.has(type.name);
62
+ case 'array':
63
+ return typeReachesDecimal(type.item, opts);
64
+ case 'lazy':
65
+ return typeReachesDecimal(type.inner, opts);
66
+ case 'tuple':
67
+ return type.items.some(t => typeReachesDecimal(t, opts));
68
+ case 'record':
69
+ return typeReachesDecimal(type.value, opts);
70
+ case 'union':
71
+ case 'discriminatedUnion':
72
+ case 'intersection':
73
+ return type.members.some(t => typeReachesDecimal(t, opts));
74
+ case 'inlineObject':
75
+ return type.fields.some(f => typeReachesDecimal(f.type, opts));
76
+ default:
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /** Fresh local names, so nested loops in one function body cannot collide. */
82
+ class Scope {
83
+ private n = 0;
84
+ next(prefix: string): string {
85
+ return `__${prefix}${this.n++}`;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Statements that hydrate `slot` — an assignable expression — in place.
91
+ *
92
+ * `path` is threaded purely for the error message; it is what tells a consumer *which* field of a
93
+ * large response was malformed.
94
+ */
95
+ function emit(slot: string, type: ContractTypeNode, path: string, opts: ReviveCodegenOptions, scope: Scope, variant: 'base' | 'output'): string[] {
96
+ switch (type.kind) {
97
+ case 'scalar':
98
+ return type.name === 'decimal' ? [`${slot} = __dec(${slot}, '${path}');`] : [];
99
+
100
+ case 'ref':
101
+ return opts.modelsWithDecimal.has(type.name) ? [`${reviveRefName(type.name, opts, variant)}(${slot} as never);`] : [];
102
+
103
+ case 'lazy':
104
+ return emit(slot, type.inner, path, opts, scope, variant);
105
+
106
+ case 'array': {
107
+ if (!typeReachesDecimal(type.item, opts)) return [];
108
+ const arr = scope.next('a');
109
+ const i = scope.next('i');
110
+ const inner = emit(`${arr}[${i}]`, type.item, `${path}[]`, opts, scope, variant);
111
+ return [
112
+ `{`,
113
+ ` const ${arr} = ${slot} as unknown[];`,
114
+ ` for (let ${i} = 0; ${i} < ${arr}.length; ${i}++) {`,
115
+ ...inner.map(l => ` ${l}`),
116
+ ` }`,
117
+ `}`,
118
+ ];
119
+ }
120
+
121
+ case 'tuple': {
122
+ const items = type.items.flatMap((t, idx) =>
123
+ typeReachesDecimal(t, opts) ? emit(`(${slot} as unknown[])[${idx}]`, t, `${path}[${idx}]`, opts, scope, variant) : [],
124
+ );
125
+ return items;
126
+ }
127
+
128
+ case 'record': {
129
+ if (!typeReachesDecimal(type.value, opts)) return [];
130
+ const rec = scope.next('r');
131
+ const k = scope.next('k');
132
+ const inner = emit(`${rec}[${k}]`, type.value, `${path}{}`, opts, scope, variant);
133
+ return [
134
+ `{`,
135
+ ` const ${rec} = ${slot} as Record<string, unknown>;`,
136
+ ` for (const ${k} of Object.keys(${rec})) {`,
137
+ ...inner.map(l => ` ${l}`),
138
+ ` }`,
139
+ `}`,
140
+ ];
141
+ }
142
+
143
+ case 'inlineObject': {
144
+ const relevant = type.fields.filter(f => typeReachesDecimal(f.type, opts));
145
+ if (relevant.length === 0) return [];
146
+ const obj = scope.next('o');
147
+ const body = relevant.flatMap(f => fieldStatements(obj, f, path, opts, scope, variant, undefined));
148
+ return [`{`, ` const ${obj} = ${slot} as Record<string, unknown>;`, ...body.map(l => ` ${l}`), `}`];
149
+ }
150
+
151
+ case 'intersection':
152
+ return type.members.flatMap(m => emit(slot, m, path, opts, scope, variant));
153
+
154
+ case 'union': {
155
+ // `validateDecimal` rejects a decimal in a union with more than one non-null member, so
156
+ // anything reaching here is `T | null`: hydrate the single real member behind a guard.
157
+ const real = type.members.filter(m => !(m.kind === 'scalar' && m.name === 'null'));
158
+ const target = real.find(m => typeReachesDecimal(m, opts));
159
+ if (!target) return [];
160
+ const inner = emit(slot, target, path, opts, scope, variant);
161
+ return [`if (${slot} != null) {`, ...inner.map(l => ` ${l}`), `}`];
162
+ }
163
+
164
+ case 'discriminatedUnion': {
165
+ const branches: string[] = [];
166
+ const disc = scope.next('d');
167
+ for (const member of type.members) {
168
+ if (!typeReachesDecimal(member, opts)) continue;
169
+ const tag = discriminatorTag(member, type.discriminator, opts);
170
+ const inner = emit(slot, member, path, opts, scope, variant);
171
+ if (inner.length === 0) continue;
172
+ if (tag === undefined) {
173
+ // No resolvable literal: hydrating unconditionally could apply the wrong arm's
174
+ // shape, so skip it rather than risk corrupting a sibling member's field.
175
+ continue;
176
+ }
177
+ branches.push(` if (${disc} === ${JSON.stringify(tag)}) {`, ...inner.map(l => ` ${l}`), ` }`);
178
+ }
179
+ if (branches.length === 0) return [];
180
+ return [`{`, ` const ${disc} = (${slot} as Record<string, unknown>)[${JSON.stringify(type.discriminator)}];`, ...branches, `}`];
181
+ }
182
+
183
+ default:
184
+ return [];
185
+ }
186
+ }
187
+
188
+ /** The literal value that selects `member` in a discriminated union, when it can be resolved. */
189
+ function discriminatorTag(member: ContractTypeNode, discriminator: string, opts: ReviveCodegenOptions): string | number | boolean | undefined {
190
+ const fields: FieldNode[] | undefined =
191
+ member.kind === 'inlineObject' ? member.fields : member.kind === 'ref' ? opts.modelMap?.get(member.name)?.fields : undefined;
192
+ const field = fields?.find(f => f.name === discriminator);
193
+ if (field?.type.kind === 'literal') return field.type.value;
194
+ // A single-valued enum is the other way a discriminator gets written.
195
+ if (field?.type.kind === 'enum' && field.type.values.length === 1) return field.type.values[0];
196
+ return undefined;
197
+ }
198
+
199
+ /** Statements for one field of an object held in `objVar`. */
200
+ function fieldStatements(
201
+ objVar: string,
202
+ field: FieldNode,
203
+ path: string,
204
+ opts: ReviveCodegenOptions,
205
+ scope: Scope,
206
+ variant: 'base' | 'output',
207
+ outputCase: 'camel' | 'snake' | 'pascal' | undefined,
208
+ ): string[] {
209
+ const key = variant === 'output' ? applyCase(field.name, outputCase) : field.name;
210
+ const slot = `${objVar}[${JSON.stringify(key)}]`;
211
+ const inner = emit(slot, field.type, `${path}.${key}`, opts, scope, variant);
212
+ if (inner.length === 0) return [];
213
+ // A union already emits its own null guard; adding a second would just nest.
214
+ if (field.type.kind === 'union') return inner;
215
+ if (field.optional || field.nullable) {
216
+ return [`if (${slot} != null) {`, ...inner.map(l => ` ${l}`), `}`];
217
+ }
218
+ return inner;
219
+ }
220
+
221
+ /**
222
+ * Pick the reviver for a referenced model.
223
+ *
224
+ * Mirrors `renderOutputTsType`: inside an output reviver, a referenced model uses its *own* output
225
+ * reviver only if it has one. `computeModelsWithOutput` propagates referrer→referenced, so a child
226
+ * of a transformed parent is not itself transformed and keeps camelCase keys.
227
+ */
228
+ function reviveRefName(name: string, opts: ReviveCodegenOptions, variant: 'base' | 'output'): string {
229
+ if (variant === 'output' && opts.modelsWithOutput?.has(name)) return reviveFnName(name, 'output');
230
+ return reviveFnName(name, 'base');
231
+ }
232
+
233
+ /**
234
+ * A standalone reviver for an arbitrary type node — used for a response body that is not a plain
235
+ * model reference (an inline object, a record, a tuple), where there is no `reviveX` to call.
236
+ *
237
+ * Returns `null` when the type holds no decimal, so the caller emits nothing at all.
238
+ */
239
+ export function renderInlineReviver(
240
+ fnName: string,
241
+ tsType: string,
242
+ type: ContractTypeNode,
243
+ opts: ReviveCodegenOptions,
244
+ variant: 'base' | 'output' = 'output',
245
+ ): string[] | null {
246
+ if (!typeReachesDecimal(type, opts)) return null;
247
+ const scope = new Scope();
248
+ const body = emit('__v[0]', type, fnName.replace(/^__revive/, ''), opts, scope, variant);
249
+ if (body.length === 0) return null;
250
+ return [
251
+ `/** Rehydrates the \`decimal\` fields of one response body. Mutates and returns \`raw\`. */`,
252
+ `function ${fnName}(raw: ${tsType}): ${tsType} {`,
253
+ ` const __v = [raw] as unknown[];`,
254
+ ...body.map(l => ` ${l}`),
255
+ ` return __v[0] as ${tsType};`,
256
+ `}`,
257
+ ];
258
+ }
259
+
260
+ /** The `reviveX` (and `reviveXOutput`) declarations for one model, or `[]` if it holds no decimal. */
261
+ export function renderReviveFunctions(model: ModelNode, opts: ReviveCodegenOptions): string[] {
262
+ if (!opts.modelsWithDecimal.has(model.name)) return [];
263
+ const lines = renderOne(model, opts, 'base');
264
+ if (opts.modelsWithOutput?.has(model.name)) {
265
+ lines.push('');
266
+ lines.push(...renderOne(model, opts, 'output'));
267
+ }
268
+ return lines;
269
+ }
270
+
271
+ function renderOne(model: ModelNode, opts: ReviveCodegenOptions, variant: 'base' | 'output'): string[] {
272
+ const scope = new Scope();
273
+ const typeName = `${model.name}${variant === 'output' ? 'Output' : ''}`;
274
+ const fnName = reviveFnName(model.name, variant);
275
+
276
+ // A type-alias model has no fields — hydrate the aliased type as a whole.
277
+ if (model.type) {
278
+ const body = emit('__v[0]', model.type, model.name, opts, scope, variant);
279
+ if (body.length === 0) return [];
280
+ return [
281
+ `/** Rehydrates every \`decimal\` in a ${typeName} from its wire string. Mutates and returns \`raw\`. */`,
282
+ `export function ${fnName}(raw: ${typeName}): ${typeName} {`,
283
+ ` const __v = [raw] as unknown[];`,
284
+ ...body.map(l => ` ${l}`),
285
+ ` return __v[0] as ${typeName};`,
286
+ `}`,
287
+ ];
288
+ }
289
+
290
+ const obj = scope.next('o');
291
+ const body = model.fields.flatMap(f =>
292
+ typeReachesDecimal(f.type, opts) ? fieldStatements(obj, f, model.name, opts, scope, variant, model.outputCase) : [],
293
+ );
294
+ if (body.length === 0) return [];
295
+
296
+ return [
297
+ `/** Rehydrates every \`decimal\` in a ${typeName} from its wire string. Mutates and returns \`raw\`. */`,
298
+ `export function ${fnName}(raw: ${typeName}): ${typeName} {`,
299
+ ` const ${obj} = raw as unknown as Record<string, unknown>;`,
300
+ ...body.map(l => ` ${l}`),
301
+ ` return raw;`,
302
+ `}`,
303
+ ];
304
+ }