@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.
@@ -7,12 +7,15 @@ import type {
7
7
  OpResponseBodyNode,
8
8
  OpResponseHeaderNode,
9
9
  ContractTypeNode,
10
+ ModelNode,
10
11
  ParamSource,
11
12
  } from '@contractkit/core';
12
13
  import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
13
14
  import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
15
  import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
15
16
  import { bodyTypesStructurallyEqual } from './codegen-operation.js';
17
+ import { reviveFnName, renderInlineReviver, typeReachesDecimal, DECIMAL_COERCE_DECL } from './codegen-revive.js';
18
+ import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
16
19
  import { basename, dirname, relative } from 'path';
17
20
 
18
21
  // ─── Body strategy ────────────────────────────────────────────────────────
@@ -81,6 +84,10 @@ export interface SdkCodegenOptions {
81
84
  modelsWithInput?: Set<string>;
82
85
  /** Set of model names that have Output variants (models with format(output=...)) */
83
86
  modelsWithOutput?: Set<string>;
87
+ /** Model names carrying a `decimal`, whose response bodies need rehydrating client-side. */
88
+ modelsWithDecimal?: Set<string>;
89
+ /** Every model in scope, for resolving discriminated-union members inside an inline reviver. */
90
+ modelMap?: Map<string, ModelNode>;
84
91
  /**
85
92
  * Whether to emit SDK methods for operations marked `internal`. Defaults to `false` —
86
93
  * internal ops are omitted from the SDK so consumers don't pick them up. Set to `true`
@@ -122,10 +129,29 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
122
129
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput, includeInternal);
123
130
  const clientClassName = options.clientClassName ?? deriveClientClassName(root.file);
124
131
 
125
- // Type-only imports
132
+ // The method bodies are generated first so the imports can be read off them, the same way
133
+ // codegen-operation decides its imports from the code it just emitted. A reviver import that
134
+ // came from a predicate instead could drift and leave an unused local behind.
135
+ const inlineRevivers = new Map<string, string[]>();
136
+ const classBody: string[] = [];
137
+ for (const route of root.routes) {
138
+ for (const op of route.operations) {
139
+ const mods = resolveModifiers(route, op);
140
+ if (!includeInternal && mods.includes('internal')) continue;
141
+ classBody.push('');
142
+ if (mods.includes('deprecated')) classBody.push(' /** @deprecated */');
143
+ classBody.push(...generateMethod(route, op, root.file, options, inlineRevivers));
144
+ }
145
+ }
146
+
147
+ const inlineReviverDecls = [...inlineRevivers.values()].flat();
148
+ const decimalPrelude = decimalPreludeFor(inlineReviverDecls);
149
+
150
+ // Type-only imports, plus the model revivers the methods actually call.
126
151
  if (types.length > 0) {
127
- lines.push(...generateTypeImports(types, root.file, options));
152
+ lines.push(...generateTypeImports(types, root.file, options, usedRevivers(classBody)));
128
153
  }
154
+ lines.push(...decimalPrelude.imports);
129
155
 
130
156
  // SdkOptions import (from shared file) or inline fallback
131
157
  if (options.sdkOptionsPath && options.outPath) {
@@ -229,6 +255,17 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
229
255
  lines.push('');
230
256
  }
231
257
 
258
+ if (decimalPrelude.decls.length > 0) {
259
+ lines.push('');
260
+ lines.push(...decimalPrelude.decls);
261
+ }
262
+
263
+ // Wrappers for bodies with no `reviveX` of their own — an inline object, a record, a tuple.
264
+ for (const decl of inlineRevivers.values()) {
265
+ lines.push('');
266
+ lines.push(...decl);
267
+ }
268
+
232
269
  // Client class
233
270
  lines.push('/**');
234
271
  const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
@@ -236,16 +273,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
236
273
  lines.push(' */');
237
274
  lines.push(`export class ${clientClassName} {`);
238
275
  lines.push(' constructor(private fetch: SdkFetch) {}');
239
-
240
- for (const route of root.routes) {
241
- for (const op of route.operations) {
242
- const mods = resolveModifiers(route, op);
243
- if (!includeInternal && mods.includes('internal')) continue;
244
- lines.push('');
245
- if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
246
- lines.push(...generateMethod(route, op, root.file, options));
247
- }
248
- }
276
+ lines.push(...classBody);
249
277
 
250
278
  lines.push('}');
251
279
  lines.push('');
@@ -255,38 +283,84 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
255
283
 
256
284
  /**
257
285
  * Render the method-block lines for an operation file as if they were declared inside a
258
- * client class. Returns one consolidated array of strings (each pre-indented for class body
259
- * level, with leading blank lines between methods) plus the set of method names emitted —
260
- * the caller uses the names to detect cross-file collisions when multiple files contribute
261
- * to the same area-level client.
286
+ * client class.
262
287
  *
263
288
  * Skips operations marked `internal` unless `options.includeInternal` is set.
289
+ *
290
+ * @returns `lines`, one consolidated array pre-indented for class-body level with leading blank
291
+ * lines between methods; `methodNames`, used by the caller to detect cross-file collisions when
292
+ * several files contribute to the same area-level client; `preludeLines`, module-level
293
+ * declarations the methods reference (decimal revivers and their `__dec` helper) which the caller
294
+ * must splice in above the class; and `needsDecimalImport`, true when those declarations require
295
+ * `import { Decimal } from 'decimal.js'` in the emitting file.
264
296
  */
265
297
  export function generateClientMethods(
266
298
  root: OpRootNode,
267
299
  options: SdkCodegenOptions,
268
- ): { lines: string[]; methodNames: string[] } {
300
+ ): { lines: string[]; methodNames: string[]; preludeLines: string[]; needsDecimalImport: boolean } {
269
301
  const lines: string[] = [];
270
302
  const methodNames: string[] = [];
271
303
  const includeInternal = options.includeInternal ?? false;
304
+ const inlineRevivers = new Map<string, string[]>();
272
305
  for (const route of root.routes) {
273
306
  for (const op of route.operations) {
274
307
  const mods = resolveModifiers(route, op);
275
308
  if (!includeInternal && mods.includes('internal')) continue;
276
309
  lines.push('');
277
310
  if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
278
- lines.push(...generateMethod(route, op, root.file, options));
311
+ lines.push(...generateMethod(route, op, root.file, options, inlineRevivers));
279
312
  methodNames.push(deriveMethodName(op, route));
280
313
  }
281
314
  }
282
- return { lines, methodNames };
315
+ // Module-level declarations the methods reference, spliced above the class by the caller —
316
+ // the same shape `generateErrorBodyAliases` already uses.
317
+ const declLines = [...inlineRevivers.values()].flat();
318
+ const { decls } = decimalPreludeFor(declLines);
319
+ const preludeLines = [...(decls.length > 0 ? ['', ...decls] : []), ...[...inlineRevivers.values()].flatMap(decl => ['', ...decl])];
320
+ return { lines, methodNames, preludeLines, needsDecimalImport: decls.length > 0 };
321
+ }
322
+
323
+ /**
324
+ * Declarations a client file needs for the inline revivers it carries.
325
+ *
326
+ * An inline wrapper calls `__dec`, which is file-local to the *types* module and not exported, so
327
+ * a client file that has one needs its own copy — along with the decimal.js import and the global
328
+ * config, since nothing else in the file necessarily pulls them in.
329
+ */
330
+ function decimalPreludeFor(declLines: string[]): { imports: string[]; decls: string[] } {
331
+ if (!declLines.some(l => l.includes('__dec('))) return { imports: [], decls: [] };
332
+ return { imports: [DECIMAL_IMPORT], decls: [DECIMAL_CONFIG_LINE, '', ...DECIMAL_COERCE_DECL] };
333
+ }
334
+
335
+ /** Model reviver names referenced by generated method bodies. `__revive…` wrappers are local. */
336
+ function usedRevivers(lines: string[]): string[] {
337
+ const found = new Set<string>();
338
+ for (const m of lines.join('\n').matchAll(/\brevive[A-Z]\w*/g)) found.add(m[0]);
339
+ return [...found].sort();
283
340
  }
284
341
 
285
342
  // ─── Method generation ────────────────────────────────────────────────────
286
343
 
287
- function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, options: SdkCodegenOptions): string[] {
344
+ function generateMethod(
345
+ route: OpRouteNode,
346
+ op: OpOperationNode,
347
+ file: string,
348
+ options: SdkCodegenOptions,
349
+ inlineRevivers?: Map<string, string[]>,
350
+ ): string[] {
351
+ const revive: ReviveContext | undefined =
352
+ options.modelsWithDecimal && options.modelsWithDecimal.size > 0 && inlineRevivers
353
+ ? {
354
+ modelsWithDecimal: options.modelsWithDecimal,
355
+ modelsWithOutput: options.modelsWithOutput,
356
+ modelMap: options.modelMap,
357
+ inlineRevivers,
358
+ nameHint: '',
359
+ }
360
+ : undefined;
288
361
  const lines: string[] = [];
289
362
  const methodName = deriveMethodName(op, route);
363
+ const mRevive = hint(revive, `${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}`);
290
364
  const httpMethod = op.method.toUpperCase();
291
365
  const { modelsWithInput, modelsWithOutput } = options;
292
366
 
@@ -456,23 +530,23 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
456
530
  lines.push(` switch (result.status) {`);
457
531
  for (const resp of rest) {
458
532
  lines.push(` case ${resp.statusCode}:`);
459
- lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true));
533
+ lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive));
460
534
  }
461
535
  lines.push(` default:`);
462
- lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true));
536
+ lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive));
463
537
  lines.push(` }`);
464
538
  } else if (primaryBodies.length > 1) {
465
- lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false));
539
+ lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive));
466
540
  } else if (hasRespHeaders) {
467
541
  const headerEntries = sdkHeaderEntries(respHeaders);
468
542
  if (isVoid) {
469
543
  lines.push(` return { headers: { ${headerEntries} } };`);
470
544
  } else {
471
- lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
545
+ lines.push(` const data = ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput, hint(mRevive, primaryResponse!.statusCode))};`);
472
546
  lines.push(` return { data, headers: { ${headerEntries} } };`);
473
547
  }
474
548
  } else if (!isVoid) {
475
- lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput)};`);
549
+ lines.push(` return ${sdkReadExpr(primaryBodies[0]!, modelsWithOutput, hint(mRevive, primaryResponse!.statusCode))};`);
476
550
  }
477
551
 
478
552
  lines.push(' }');
@@ -490,12 +564,75 @@ function sdkDataType(body: OpResponseBodyNode, modelsWithOutput?: Set<string>):
490
564
  return renderOutputTsType(body.bodyType, modelsWithOutput);
491
565
  }
492
566
 
493
- /** How a client reads one response body off the `Response`. */
494
- function sdkReadExpr(body: OpResponseBodyNode, modelsWithOutput?: Set<string>): string {
567
+ /**
568
+ * How a client reads one response body off the `Response`.
569
+ *
570
+ * A decimal-bearing body is wrapped in its reviver: `parseJson` is a bare cast, so without this the
571
+ * type would say `Decimal` while the runtime held a string. Bodies with no decimal are emitted
572
+ * byte-identically to before.
573
+ */
574
+ function sdkReadExpr(body: OpResponseBodyNode, modelsWithOutput?: Set<string>, revive?: ReviveContext): string {
495
575
  const category = classifyContentType(body.contentType);
496
576
  if (category === 'text') return 'await result.text()';
497
577
  if (category === 'binary') return 'await result.blob()';
498
- return `await parseJson<${renderOutputTsType(body.bodyType, modelsWithOutput)}>(result)`;
578
+ const tsType = renderOutputTsType(body.bodyType, modelsWithOutput);
579
+ const read = `await parseJson<${tsType}>(result)`;
580
+ const reviver = reviveExprFor(body.bodyType, revive);
581
+ if (!reviver) return read;
582
+ // `.map` over an array of refs rather than a wrapper function: the reviver returns the same
583
+ // object it mutated, so the mapped array holds the same elements.
584
+ return reviver.kind === 'array' ? `(${read}).map(${reviver.name})` : `${reviver.name}(${read})`;
585
+ }
586
+
587
+ /**
588
+ * Extend the inline-reviver name with one more path segment.
589
+ *
590
+ * Built up compositionally — method, then status, then mime index — because each of those lives in
591
+ * a different function, and the resulting name has to be distinct per body: two operations with
592
+ * different inline decimal bodies must not share one wrapper.
593
+ */
594
+ function hint(revive: ReviveContext | undefined, segment: string | number): ReviveContext | undefined {
595
+ if (!revive) return undefined;
596
+ return { ...revive, nameHint: `${revive.nameHint}${segment}` };
597
+ }
598
+
599
+ /** What `sdkReadExpr` needs to decide whether a body has to be revived, and with which function. */
600
+ interface ReviveContext {
601
+ modelsWithDecimal: Set<string>;
602
+ modelsWithOutput?: Set<string>;
603
+ /** Inline reviver declarations accumulated for the current file, keyed by function name. */
604
+ inlineRevivers: Map<string, string[]>;
605
+ modelMap?: Map<string, ModelNode>;
606
+ /** Distinguishes the inline wrapper emitted for each body. */
607
+ nameHint: string;
608
+ }
609
+
610
+ /** The reviver to apply to a response body, or `null` when the body holds no decimal. */
611
+ function reviveExprFor(bodyType: ContractTypeNode, ctx: ReviveContext | undefined): { name: string; kind: 'value' | 'array' } | null {
612
+ if (!ctx || ctx.modelsWithDecimal.size === 0) return null;
613
+ const opts = { modelsWithDecimal: ctx.modelsWithDecimal, modelsWithOutput: ctx.modelsWithOutput, modelMap: ctx.modelMap };
614
+ if (!typeReachesDecimal(bodyType, opts)) return null;
615
+
616
+ const refName = (t: ContractTypeNode): string | null => (t.kind === 'ref' ? t.name : t.kind === 'lazy' ? refName(t.inner) : null);
617
+ const pick = (name: string) => reviveFnName(name, ctx.modelsWithOutput?.has(name) ? 'output' : 'base');
618
+
619
+ const direct = refName(bodyType);
620
+ if (direct && ctx.modelsWithDecimal.has(direct)) return { name: pick(direct), kind: 'value' };
621
+
622
+ if (bodyType.kind === 'array') {
623
+ const item = refName(bodyType.item);
624
+ if (item && ctx.modelsWithDecimal.has(item)) return { name: pick(item), kind: 'array' };
625
+ }
626
+
627
+ // Anything else — an inline object, a record, a tuple — has no `reviveX` to call, so the file
628
+ // gets a wrapper of its own.
629
+ const fnName = `__revive${ctx.nameHint}`;
630
+ if (!ctx.inlineRevivers.has(fnName)) {
631
+ const decl = renderInlineReviver(fnName, renderOutputTsType(bodyType, ctx.modelsWithOutput), bodyType, opts);
632
+ if (!decl) return null;
633
+ ctx.inlineRevivers.set(fnName, decl);
634
+ }
635
+ return { name: fnName, kind: 'value' };
499
636
  }
500
637
 
501
638
  function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
@@ -535,7 +672,13 @@ function sdkResponseMembers(resp: OpResponseNode, modelsWithOutput: Set<string>
535
672
  }
536
673
 
537
674
  /** The `return` statement(s) that build one response's member of the return union. */
538
- function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | undefined, indent: string, includeStatus: boolean): string[] {
675
+ function sdkReturnLines(
676
+ resp: OpResponseNode,
677
+ modelsWithOutput: Set<string> | undefined,
678
+ indent: string,
679
+ includeStatus: boolean,
680
+ revive?: ReviveContext,
681
+ ): string[] {
539
682
  const bodies = resp.bodies;
540
683
  const headers = resp.headers ?? [];
541
684
  const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
@@ -545,7 +688,12 @@ function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | un
545
688
  return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
546
689
  }
547
690
  if (bodies.length === 1) {
548
- const fields = [...leading, `contentType: '${bodies[0]!.contentType}'`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
691
+ const fields = [
692
+ ...leading,
693
+ `contentType: '${bodies[0]!.contentType}'`,
694
+ `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput, hint(revive, resp.statusCode))}`,
695
+ ...trailing,
696
+ ];
549
697
  return [`${indent}return { ${fields.join(', ')} };`];
550
698
  }
551
699
 
@@ -553,19 +701,34 @@ function sdkReturnLines(resp: OpResponseNode, modelsWithOutput: Set<string> | un
553
701
  if (dataTypes.every(t => t === dataTypes[0])) {
554
702
  // Every mime reads the same way, so only the label has to come off the wire.
555
703
  const cast = bodies.map(b => `'${b.contentType}'`).join(' | ');
556
- const fields = [...leading, `contentType: readContentType(result) as ${cast}`, `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput)}`, ...trailing];
704
+ const fields = [
705
+ ...leading,
706
+ `contentType: readContentType(result) as ${cast}`,
707
+ `data: ${sdkReadExpr(bodies[0]!, modelsWithOutput, hint(revive, resp.statusCode))}`,
708
+ ...trailing,
709
+ ];
557
710
  return [`${indent}return { ${fields.join(', ')} };`];
558
711
  }
559
712
 
560
713
  // The mimes read differently, so the client has to dispatch on what actually came back.
561
714
  const lines = [`${indent}switch (readContentType(result)) {`];
562
- for (const body of bodies.slice(1)) {
563
- const fields = [...leading, `contentType: '${body.contentType}'`, `data: ${sdkReadExpr(body, modelsWithOutput)}`, ...trailing];
715
+ for (const [i, body] of bodies.slice(1).entries()) {
716
+ const fields = [
717
+ ...leading,
718
+ `contentType: '${body.contentType}'`,
719
+ `data: ${sdkReadExpr(body, modelsWithOutput, hint(revive, `${resp.statusCode}_${i + 1}`))}`,
720
+ ...trailing,
721
+ ];
564
722
  lines.push(`${indent} case '${body.contentType}':`);
565
723
  lines.push(`${indent} return { ${fields.join(', ')} };`);
566
724
  }
567
725
  const first = bodies[0]!;
568
- const fallbackFields = [...leading, `contentType: '${first.contentType}'`, `data: ${sdkReadExpr(first, modelsWithOutput)}`, ...trailing];
726
+ const fallbackFields = [
727
+ ...leading,
728
+ `contentType: '${first.contentType}'`,
729
+ `data: ${sdkReadExpr(first, modelsWithOutput, hint(revive, `${resp.statusCode}_0`))}`,
730
+ ...trailing,
731
+ ];
569
732
  lines.push(`${indent} default:`);
570
733
  lines.push(`${indent} return { ${fallbackFields.join(', ')} };`);
571
734
  lines.push(`${indent}}`);
@@ -1026,7 +1189,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
1026
1189
 
1027
1190
  // ─── Type import resolution ───────────────────────────────────────────────
1028
1191
 
1029
- function generateTypeImports(types: string[], opFile: string, options: SdkCodegenOptions): string[] {
1192
+ function generateTypeImports(types: string[], opFile: string, options: SdkCodegenOptions, revivers: string[] = []): string[] {
1030
1193
  const lines: string[] = [];
1031
1194
  const { modelOutPaths, outPath } = options;
1032
1195
 
@@ -1051,6 +1214,9 @@ function generateTypeImports(types: string[], opFile: string, options: SdkCodege
1051
1214
  rel = rel.replace(/\.ts$/, '.js');
1052
1215
  if (!rel.startsWith('.')) rel = './' + rel;
1053
1216
  lines.push(`import type { ${names.sort().join(', ')} } from '${rel}';`);
1217
+ // Revivers are values, so they need a second, non-type import from the same module.
1218
+ const fromHere = revivers.filter(r => modelOutPaths.get(reviverModelName(r, names)) === typeOutPath);
1219
+ if (fromHere.length > 0) lines.push(`import { ${fromHere.sort().join(', ')} } from '${rel}';`);
1054
1220
  }
1055
1221
 
1056
1222
  for (const type of unresolved) {
@@ -1065,6 +1231,18 @@ function generateTypeImports(types: string[], opFile: string, options: SdkCodege
1065
1231
  return lines;
1066
1232
  }
1067
1233
 
1234
+ /**
1235
+ * The model a reviver belongs to. `reviveInvoiceOutput` can come from either `Invoice` (with an
1236
+ * `Output` variant) or a model literally called `InvoiceOutput`, so the names actually imported
1237
+ * from the module decide it.
1238
+ */
1239
+ function reviverModelName(reviver: string, namesInModule: string[]): string {
1240
+ const stem = reviver.replace(/^revive/, '');
1241
+ if (namesInModule.includes(stem)) return stem;
1242
+ const base = stem.replace(/Output$/, '');
1243
+ return namesInModule.includes(base) ? base : stem;
1244
+ }
1245
+
1068
1246
  function deriveTypeImportPath(file: string, template?: string): string {
1069
1247
  const base =
1070
1248
  file
@@ -1180,6 +1358,7 @@ export function generateSdkOptions(): string {
1180
1358
  const SCAFFOLD_DEP_VERSIONS = {
1181
1359
  zod: '^4.3.6',
1182
1360
  luxon: '^3.5.0',
1361
+ decimalJs: '^10.4.3',
1183
1362
  typesLuxon: '^3.4.2',
1184
1363
  typescript: '^6.0.3',
1185
1364
  } as const;
@@ -1188,8 +1367,10 @@ const SCAFFOLD_DEP_VERSIONS = {
1188
1367
  export interface SdkScaffoldDeps {
1189
1368
  /** Zod schema files are emitted (`config.zod`) — the SDK imports `zod`. */
1190
1369
  zod: boolean;
1191
- /** Any covered model uses a `date`/`time`/`datetime`/`interval` scalar — the SDK imports `luxon`. */
1370
+ /** Any covered model uses a `date`/`time`/`datetime`/`duration`/`interval` scalar — the SDK imports `luxon`. */
1192
1371
  luxon: boolean;
1372
+ /** Any covered model uses a `decimal` scalar — the SDK imports `decimal.js`. */
1373
+ decimal: boolean;
1193
1374
  }
1194
1375
 
1195
1376
  /**
@@ -1201,6 +1382,8 @@ export function generateSdkPackageJson(input: { name: string; deps: SdkScaffoldD
1201
1382
  const dependencies: Record<string, string> = {};
1202
1383
  if (input.deps.zod) dependencies.zod = SCAFFOLD_DEP_VERSIONS.zod;
1203
1384
  if (input.deps.luxon) dependencies.luxon = SCAFFOLD_DEP_VERSIONS.luxon;
1385
+ // No `@types/` half — decimal.js ships its own declarations.
1386
+ if (input.deps.decimal) dependencies['decimal.js'] = SCAFFOLD_DEP_VERSIONS.decimalJs;
1204
1387
 
1205
1388
  const devDependencies: Record<string, string> = { typescript: SCAFFOLD_DEP_VERSIONS.typescript };
1206
1389
  if (input.deps.luxon) devDependencies['@types/luxon'] = SCAFFOLD_DEP_VERSIONS.typesLuxon;
@@ -1330,6 +1513,10 @@ export function generateAreaClient(input: AreaClientInput): string {
1330
1513
 
1331
1514
  // ── Merge inputs across all inline files ────────────────────────────────
1332
1515
  const collectedMethodLines: string[] = [];
1516
+ const collectedRevivePrelude: string[] = [];
1517
+ let areaNeedsDecimalImport = false;
1518
+ /** Reviver value imports, grouped the same way `typesByImportPath` groups the type imports. */
1519
+ const reviversByImportPath = new Map<string, Set<string>>();
1333
1520
  // Aliases are keyed off method names, which already collide-check below, so a Set is enough.
1334
1521
  const collectedErrorAliases = new Set<string>();
1335
1522
  const seenMethods = new Set<string>();
@@ -1343,7 +1530,9 @@ export function generateAreaClient(input: AreaClientInput): string {
1343
1530
 
1344
1531
  for (const inline of inlineFiles) {
1345
1532
  const includeInternal = inline.codegenOptions.includeInternal ?? false;
1346
- const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
1533
+ const { lines: methodLines, methodNames, preludeLines, needsDecimalImport } = generateClientMethods(inline.root, inline.codegenOptions);
1534
+ collectedRevivePrelude.push(...preludeLines);
1535
+ if (needsDecimalImport) areaNeedsDecimalImport = true;
1347
1536
  for (const name of methodNames) {
1348
1537
  if (seenMethods.has(name)) {
1349
1538
  throw new Error(
@@ -1360,6 +1549,19 @@ export function generateAreaClient(input: AreaClientInput): string {
1360
1549
  if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1361
1550
  if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
1362
1551
 
1552
+ // Revivers this file's methods call, resolved against the same modelOutPaths. Derived from
1553
+ // the emitted lines, exactly as `generateSdk` does, so the two paths cannot disagree.
1554
+ for (const reviver of usedRevivers(methodLines)) {
1555
+ const stem = reviver.replace(/^revive/, '');
1556
+ const modelOut = inline.codegenOptions.modelOutPaths?.get(stem) ?? inline.codegenOptions.modelOutPaths?.get(stem.replace(/Output$/, ''));
1557
+ if (!modelOut) continue;
1558
+ let rel = relative(dirname(outPath), modelOut).replace(/\.ts$/, '.js');
1559
+ if (!rel.startsWith('.')) rel = './' + rel;
1560
+ const set = reviversByImportPath.get(rel) ?? new Set<string>();
1561
+ set.add(reviver);
1562
+ reviversByImportPath.set(rel, set);
1563
+ }
1564
+
1363
1565
  // Resolve each file's type refs against THIS file's modelOutPaths, but
1364
1566
  // produce import paths relative to the area client's outPath (not the
1365
1567
  // contributing file's outPath, which pointed at the now-defunct sdk.ts).
@@ -1406,11 +1608,15 @@ export function generateAreaClient(input: AreaClientInput): string {
1406
1608
  for (const path of [...typesByImportPath.keys()].sort()) {
1407
1609
  const names = [...typesByImportPath.get(path)!].sort();
1408
1610
  lines.push(`import type { ${names.join(', ')} } from '${path}';`);
1611
+ const revivers = reviversByImportPath.get(path);
1612
+ if (revivers && revivers.size > 0) lines.push(`import { ${[...revivers].sort().join(', ')} } from '${path}';`);
1409
1613
  }
1410
1614
  for (const t of [...unresolvedTypes].sort()) {
1411
1615
  lines.push(`import type { ${t} } from './${pascalToDotCase(t)}.js';`);
1412
1616
  }
1413
1617
 
1618
+ if (areaNeedsDecimalImport) lines.push(DECIMAL_IMPORT);
1619
+
1414
1620
  // Leaf client imports (subareas only — top-level clients live next to sdk.ts).
1415
1621
  const importedClients = new Set<string>();
1416
1622
  for (const sc of subareaClients) {
@@ -1426,6 +1632,11 @@ export function generateAreaClient(input: AreaClientInput): string {
1426
1632
  lines.push('');
1427
1633
  }
1428
1634
 
1635
+ if (collectedRevivePrelude.length > 0) {
1636
+ lines.push(...collectedRevivePrelude);
1637
+ lines.push('');
1638
+ }
1639
+
1429
1640
  // ── <Area>Client class ──────────────────────────────────────────────────
1430
1641
  lines.push(`export class ${className} {`);
1431
1642
  for (const sc of subareaClients) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The emitted decimal.js runtime shared by every generator that renders a `decimal` scalar.
3
+ *
4
+ * `_ZodBinary`/`_ZodDatetime`/`_ZodInterval` are duplicated as literals across codegen-contract,
5
+ * codegen-operation and codegen-mcp, and have already drifted once (the three files emit their
6
+ * luxon import lists in two different orders). The decimal runtime is defined once here so the
7
+ * three call sites cannot disagree about it; each still decides *whether* to emit it with its own
8
+ * detection strategy, which is the part that legitimately differs between them.
9
+ */
10
+
11
+ /**
12
+ * The decimal.js import — **named, not default**.
13
+ *
14
+ * `decimal.d.ts` declares `Decimal` three times over: a class, a namespace, and a function, with
15
+ * `export default Decimal` alongside. Under the `NodeNext` resolution the scaffolded SDK uses
16
+ * (`module: NodeNext` + `"type": "module"`), the default export resolves to the *namespace*
17
+ * meaning, so `import Decimal from 'decimal.js'` fails to compile with "Cannot use namespace
18
+ * 'Decimal' as a type" and "Property 'set' does not exist". The named import binds the merged
19
+ * class and is the only form that typechecks.
20
+ */
21
+ export const DECIMAL_IMPORT = `import { Decimal } from 'decimal.js';`;
22
+
23
+ /**
24
+ * Global decimal.js configuration, emitted in **every** file that imports `Decimal` — plain-types
25
+ * mode included, where there is no Zod schema but `String(value)` and `JSON.stringify` still run.
26
+ *
27
+ * Load-bearing, not cosmetic. decimal.js switches to exponential
28
+ * notation outside `toExpNeg`/`toExpPos` (defaults -7/21), so without it `new Decimal('0.00000001')`
29
+ * serializes as `"1e-8"` and any peer validating `^-?\d+(\.\d+)?$` rejects it. It is the only lever
30
+ * that reaches the `JSON.stringify` Koa runs over `ctx.body`, which we do not otherwise control.
31
+ *
32
+ */
33
+ export const DECIMAL_CONFIG_LINE = `Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });`;
34
+
35
+ /**
36
+ * The Zod schema for a `decimal`.
37
+ *
38
+ * Deliberately has no output `.transform()`. `isRevalidatable` in codegen-operation treats every
39
+ * scalar as idempotent under re-parse — the assumption `server.validateResponses` rests on — and
40
+ * preprocess passes an already-`Decimal` value through untouched, so it holds.
41
+ *
42
+ * A raw JSON number fails validation rather than being coerced: by the time one reaches us it has
43
+ * already been through an IEEE-754 double, which is the loss this scalar exists to prevent. Bad
44
+ * strings are returned unchanged from preprocess rather than throwing, so they surface as an
45
+ * ordinary Zod issue instead of a `DecimalError` escaping the parse.
46
+ */
47
+ export const DECIMAL_ZOD_SCHEMA_LINE = `const _ZodDecimal = z.preprocess((val) => { if (typeof val !== 'string') return val; try { return new Decimal(val); } catch { return val; } }, z.custom<Decimal>((val) => Decimal.isDecimal(val), { message: 'Must be an exact decimal sent as a quoted string, e.g. "1250.00"' }));`;
48
+
49
+ /** The decimal runtime for a file that also holds Zod schemas, in emission order. */
50
+ export const DECIMAL_PRELUDE_LINES: readonly string[] = [DECIMAL_CONFIG_LINE, DECIMAL_ZOD_SCHEMA_LINE];