@contractkit/plugin-typescript 0.32.0 → 0.33.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.
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAgDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAsDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,11 +27,11 @@
27
27
  ".": "./dist/index.js"
28
28
  },
29
29
  "dependencies": {
30
- "@contractkit/core": "0.27.0"
30
+ "@contractkit/core": "0.28.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@repo/config-eslint": "0.3.1",
34
- "@repo/config-typescript": "0.1.0"
33
+ "@repo/config-typescript": "0.1.0",
34
+ "@repo/config-eslint": "0.3.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -23,6 +23,8 @@ import {
23
23
  } from '@contractkit/core';
24
24
  import { escapeJsDocLines } from './ts-render.js';
25
25
  import type { TsRenderTarget } from './ts-render.js';
26
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
27
+ import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
26
28
 
27
29
  /**
28
30
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -60,6 +62,13 @@ export interface ContractCodegenContext {
60
62
  * `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
61
63
  */
62
64
  target?: TsRenderTarget;
65
+ /** Model names that carry a `decimal`, directly or transitively. */
66
+ modelsWithDecimal?: Set<string>;
67
+ /**
68
+ * Emit `reviveX()` hydration functions alongside the schemas. Set only for SDK type files: a
69
+ * server handler receives decimals already parsed by `_ZodDecimal`, so it has nothing to revive.
70
+ */
71
+ emitRevivers?: boolean;
63
72
  }
64
73
 
65
74
  // ─── Public entry point ────────────────────────────────────────────────────
@@ -142,6 +151,7 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
142
151
  const needsBinary = rootNeedsScalar(root, 'binary');
143
152
  const needsDatetime = rootNeedsScalar(root, 'datetime');
144
153
  const needsJson = rootNeedsScalar(root, 'json');
154
+ const needsDecimal = rootNeedsScalar(root, 'decimal');
145
155
  const externalRefs = collectExternalRefs(root);
146
156
  const lines: string[] = [];
147
157
 
@@ -166,9 +176,14 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
166
176
  if (needsDuration) luxonImports.push('Duration');
167
177
  if (needsInterval) luxonImports.push('Interval');
168
178
  if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
179
+ if (needsDecimal) lines.push(DECIMAL_IMPORT);
169
180
  for (const ref of allExternalRefs) {
170
181
  const importPath = resolveImportPath(ref, context);
171
- lines.push(`import { ${ref} } from '${importPath}';`);
182
+ // A cross-file model that carries a decimal contributes its reviver too — the local
183
+ // reviver calls it rather than re-deriving the other file's shape.
184
+ const names =
185
+ context?.emitRevivers && context.modelsWithDecimal?.has(ref) ? `${ref}, ${reviveFnName(ref)}` : ref;
186
+ lines.push(`import { ${names} } from '${importPath}';`);
172
187
  }
173
188
  lines.push('');
174
189
  if (needsBinary) {
@@ -184,21 +199,45 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
184
199
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
185
200
  );
186
201
  }
202
+ if (needsDecimal) {
203
+ lines.push(...DECIMAL_PRELUDE_LINES);
204
+ }
187
205
  if (needsJson) {
188
206
  lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
189
207
  lines.push(
190
208
  `const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
191
209
  );
192
210
  }
193
- if (needsBinary || needsDatetime || needsInterval || needsJson) lines.push('');
211
+ if (needsBinary || needsDatetime || needsInterval || needsDecimal || needsJson) lines.push('');
194
212
 
195
213
  const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
196
214
  const modelMap = new Map(root.models.map(m => [m.name, m]));
197
215
 
216
+ const reviveOpts =
217
+ context?.emitRevivers && context.modelsWithDecimal
218
+ ? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
219
+ : undefined;
220
+
221
+ const bodyLines: string[] = [];
198
222
  for (const model of topoSortModels(root.models)) {
199
- lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
223
+ bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
224
+ if (reviveOpts) {
225
+ const revivers = renderReviveFunctions(model, reviveOpts);
226
+ if (revivers.length > 0) {
227
+ bodyLines.push('');
228
+ bodyLines.push(...revivers);
229
+ }
230
+ }
231
+ bodyLines.push('');
232
+ }
233
+
234
+ // Decided from the emitted revivers rather than from a predicate over the AST, so the
235
+ // declaration and its uses cannot drift apart and leave an unused local behind.
236
+ if (bodyLines.some(l => l.includes('__dec('))) {
237
+ lines.push(...DECIMAL_COERCE_DECL);
200
238
  lines.push('');
201
239
  }
240
+ lines.push(...bodyLines);
202
241
 
203
242
  return lines.join('\n');
204
243
  }
@@ -631,6 +670,22 @@ function renderScalar(s: ScalarTypeNode): string {
631
670
  if (s.max !== undefined) inner += `.max(${s.max}n)`;
632
671
  return `z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, ${inner})`;
633
672
  }
673
+ case 'decimal': {
674
+ // Deliberately no output `.transform()`: `isRevalidatable` in codegen-operation treats
675
+ // every scalar as idempotent under re-parse, which `server.validateResponses` relies on.
676
+ // Preprocess passes an already-`Decimal` value straight through, so parse(parse(x)) is
677
+ // stable. Modelling this on `_ZodInterval` — which does transform — would break that.
678
+ const checks: string[] = [];
679
+ if (s.scale !== undefined) checks.push(`v.decimalPlaces() <= ${s.scale}`);
680
+ if (s.min !== undefined) checks.push(`v.gte('${escapeString(String(s.min))}')`);
681
+ if (s.max !== undefined) checks.push(`v.lte('${escapeString(String(s.max))}')`);
682
+ if (checks.length === 0) return '_ZodDecimal';
683
+ const messageParts: string[] = [];
684
+ if (s.scale !== undefined) messageParts.push(`at most ${s.scale} decimal place${s.scale === 1 ? '' : 's'}`);
685
+ if (s.min !== undefined) messageParts.push(`at least ${s.min}`);
686
+ if (s.max !== undefined) messageParts.push(`at most ${s.max}`);
687
+ return `_ZodDecimal.refine((v) => ${checks.join(' && ')}, { message: 'Must be ${escapeString(messageParts.join(', '))}' })`;
688
+ }
634
689
  case 'boolean':
635
690
  return `z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`;
636
691
  case 'date': {
@@ -3,6 +3,7 @@ import { resolveModifiers, emittedResponses } from '@contractkit/core';
3
3
  import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
4
4
  import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
5
5
  import { quoteKey, escapeSingleQuoted } from './ts-render.js';
6
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
6
7
  import { basename, dirname, relative } from 'node:path';
7
8
 
8
9
  // ─── Options ────────────────────────────────────────────────────────────────
@@ -289,6 +290,9 @@ function scalarHelperLines(body: string): string[] {
289
290
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
290
291
  );
291
292
  }
293
+ if (body.includes('_ZodDecimal')) {
294
+ lines.push(...DECIMAL_PRELUDE_LINES);
295
+ }
292
296
  if (body.includes('_ZodInterval')) {
293
297
  lines.push(
294
298
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
@@ -430,6 +434,7 @@ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {
430
434
  if (/\bInterval\b/.test(bodyWithHelpers)) luxon.push('Interval');
431
435
  if (/\bDuration\b/.test(bodyWithHelpers)) luxon.push('Duration');
432
436
  if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
437
+ if (/\bDecimal\b/.test(bodyWithHelpers)) imports.push(DECIMAL_IMPORT);
433
438
 
434
439
  imports.push(`import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';`);
435
440
  imports.push(`import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';`);
@@ -18,6 +18,7 @@ import {
18
18
  modeToWrapper,
19
19
  } from './codegen-contract.js';
20
20
  import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
21
+ import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
21
22
  import { basename, dirname, relative } from 'path';
22
23
 
23
24
  // ─── Content-type helpers ──────────────────────────────────────────────────
@@ -50,7 +51,15 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
50
51
  switch (a.kind) {
51
52
  case 'scalar': {
52
53
  const bb = b as typeof a;
53
- return a.name === bb.name && a.min === bb.min && a.max === bb.max && a.len === bb.len && a.regex === bb.regex && a.format === bb.format;
54
+ return (
55
+ a.name === bb.name &&
56
+ a.min === bb.min &&
57
+ a.max === bb.max &&
58
+ a.len === bb.len &&
59
+ a.scale === bb.scale &&
60
+ a.regex === bb.regex &&
61
+ a.format === bb.format
62
+ );
54
63
  }
55
64
  case 'array': {
56
65
  const bb = b as typeof a;
@@ -197,6 +206,9 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
197
206
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
198
207
  );
199
208
  }
209
+ if (references('_ZodDecimal')) {
210
+ helpers.push(...DECIMAL_PRELUDE_LINES);
211
+ }
200
212
  if (references('_ZodInterval')) {
201
213
  helpers.push(
202
214
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
@@ -247,6 +259,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
247
259
  body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
248
260
  }
249
261
 
262
+ // Same `uses` gate: `Decimal` appears in the `_ZodDecimal` helper and in service-result
263
+ // annotations via `serverTsScalar`, and the helper text is folded into `generated` above.
264
+ if (uses('Decimal')) {
265
+ body.push(DECIMAL_IMPORT);
266
+ }
267
+
250
268
  if (uses('parseAndValidate')) {
251
269
  body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
252
270
  }
@@ -645,6 +663,10 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
645
663
  return 'number';
646
664
  case 'bigint':
647
665
  return 'bigint';
666
+ case 'decimal':
667
+ // Unlike the date scalars, this matches `renderTsScalar`'s wire view — `_ZodDecimal`
668
+ // has no output transform, so `z.infer` is a `Decimal` on both sides.
669
+ return 'Decimal';
648
670
  case 'boolean':
649
671
  return 'boolean';
650
672
  case 'date':
@@ -12,6 +12,8 @@ import {
12
12
  } from './codegen-contract.js';
13
13
  import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
14
  import type { TsRenderTarget } from './ts-render.js';
15
+ import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
16
+ import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
15
17
 
16
18
  // ─── Public entry point ────────────────────────────────────────────────────
17
19
 
@@ -44,10 +46,20 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
44
46
  const externalOutputRefs = allModelsWithOutput.size > 0 ? collectExternalOutputRefs(root, allModelsWithOutput) : [];
45
47
  const allExternalRefs = [...new Set([...externalRefs, ...externalInputRefs, ...externalOutputRefs])].sort();
46
48
 
47
- // Type-only imports for external references
49
+ // Not `import type`: `renderTsScalar` maps `decimal` to `Decimal` in this mode too, so the class
50
+ // is a real runtime dependency of any consumer holding one — same position as in
51
+ // `generateContract`, which emits it ahead of the external model refs.
52
+ const needsDecimal = rootNeedsScalar(root, 'decimal') || (context?.emitRevivers && context.modelsWithDecimal ? root.models.some(m => context.modelsWithDecimal!.has(m.name)) : false);
53
+ if (needsDecimal) lines.push(DECIMAL_IMPORT);
54
+
55
+ // Type-only imports for external references. A cross-file model carrying a decimal also
56
+ // contributes its reviver, which is a value and so needs a second, non-type import.
48
57
  for (const ref of allExternalRefs) {
49
58
  const importPath = resolveImportPath(ref, context);
50
59
  lines.push(`import type { ${ref} } from '${importPath}';`);
60
+ if (context?.emitRevivers && context.modelsWithDecimal?.has(ref)) {
61
+ lines.push(`import { ${reviveFnName(ref)} } from '${importPath}';`);
62
+ }
51
63
  }
52
64
  if (allExternalRefs.length > 0) lines.push('');
53
65
 
@@ -62,10 +74,38 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
62
74
 
63
75
  const modelMap = new Map(root.models.map(m => [m.name, m]));
64
76
 
77
+ const reviveOpts =
78
+ context?.emitRevivers && context.modelsWithDecimal
79
+ ? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
80
+ : undefined;
81
+
82
+ const bodyLines: string[] = [];
65
83
  for (const model of topoSortModels(root.models)) {
66
- lines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
84
+ bodyLines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
85
+ if (reviveOpts) {
86
+ const revivers = renderReviveFunctions(model, reviveOpts);
87
+ if (revivers.length > 0) {
88
+ bodyLines.push('');
89
+ bodyLines.push(...revivers);
90
+ }
91
+ }
92
+ bodyLines.push('');
93
+ }
94
+
95
+ // Global decimal.js config belongs in any file holding a `Decimal`: there is no Zod schema in
96
+ // this mode, but `String(value)` and `JSON.stringify` still have to stay out of exponential form.
97
+ if (needsDecimal) {
98
+ lines.push('');
99
+ lines.push(DECIMAL_CONFIG_LINE);
100
+ }
101
+
102
+ // Same rule as in `generateContract`: the helper is emitted only if the revivers actually
103
+ // reference it, so the two cannot drift and trip `noUnusedLocals`.
104
+ if (bodyLines.some(l => l.includes('__dec('))) {
105
+ lines.push(...DECIMAL_COERCE_DECL);
67
106
  lines.push('');
68
107
  }
108
+ lines.push(...bodyLines);
69
109
 
70
110
  return lines.join('\n');
71
111
  }
@@ -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
+ }