@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.
@@ -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/llms.txt ADDED
@@ -0,0 +1,111 @@
1
+ # @contractkit/plugin-typescript
2
+
3
+ > ContractKit's TypeScript code generator. From the same `.ck` files it emits Koa routers, a
4
+ > TypeScript SDK client, Zod schemas, plain TypeScript types, and MCP tool handlers. Five
5
+ > independent sub-generators, each enabled by the presence of its config key.
6
+
7
+ Loaded by `@contractkit/cli` through `contractkit.config.json`. It consumes the AST produced by
8
+ `@contractkit/core`; you do not call it directly in a normal build.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add -D @contractkit/cli @contractkit/plugin-typescript
14
+ ```
15
+
16
+ ## Configure
17
+
18
+ `plugins` is an **object** keyed by package name, not an array. Each sub-config is independent —
19
+ include only the ones you want; omitting a key disables that sub-generator entirely.
20
+
21
+ ```json
22
+ {
23
+ "rootDir": ".",
24
+ "patterns": ["contracts/**/*.ck"],
25
+ "plugins": {
26
+ "@contractkit/plugin-typescript": {
27
+ "server": {
28
+ "baseDir": "apps/api/",
29
+ "zod": true,
30
+ "output": {
31
+ "routes": "src/routes/{filename}.router.ts",
32
+ "types": "src/modules/{area}/types/{filename}.ts"
33
+ },
34
+ "servicePathTemplate": "#modules/{module}/{module}.service.js"
35
+ },
36
+ "sdk": {
37
+ "baseDir": "packages/sdk/",
38
+ "name": "acme",
39
+ "zod": true,
40
+ "output": {
41
+ "sdk": "src/{name}.sdk.ts",
42
+ "types": "src/{area}/types/{filename}.ts",
43
+ "clients": "src/{area}/{filename}.client.ts"
44
+ }
45
+ },
46
+ "zod": { "baseDir": "packages/schemas/", "output": "{filename}.schema.ts" },
47
+ "types": { "baseDir": "packages/types/", "output": "{filename}.types.ts" }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Path templates accept `{filename}`, `{dir}`, `{area}`, and — in `sdk.output.sdk` — `{name}`.
54
+
55
+ ## Sub-configs
56
+
57
+ | Key | Emits |
58
+ | --- | --- |
59
+ | `server` | Koa routers from `operation` declarations, plus the type or Zod files they import |
60
+ | `sdk` | The SDK class, per-area operation clients, and their types |
61
+ | `zod` | Standalone Zod schemas, independent of `server` and `sdk` |
62
+ | `types` | Standalone plain TypeScript types, independent of `server` and `sdk` |
63
+ | `mcp` | Per-file MCP tool handlers, an aggregator, and an optional `POST /mcp` route |
64
+
65
+ Options worth knowing:
66
+
67
+ - `server.zod` / `sdk.zod` — when true, `output.types` emits Zod schema files instead of plain
68
+ interfaces. Off by default.
69
+ - `server.validateResponses` — each handler re-parses the service result through its declared
70
+ response schema before writing `ctx.body`. **Requires `zod: true`**; without it `output.types`
71
+ emits plain interfaces, which are types with no runtime value to parse through. A body that
72
+ transitively references a model with `format(input=…)`/`format(output=…)`, and a status whose
73
+ several mimes carry different body types, are left unvalidated.
74
+ - `sdk.scaffold` — write-once starter `package.json` and `tsconfig.json` at the SDK `baseDir`, so
75
+ the generated output is a publishable package on its own. Never overwritten once created, so your
76
+ edits survive. Dependency ranges are derived from the contracts.
77
+ - `includeInternal` on `server`/`sdk` — whether operations marked `internal` get handlers or client
78
+ methods. Defaults to `true` for `server`, `false` for `sdk`.
79
+ - `types.target` — `'client'` (default) or `'server'`; decides whether `binary` renders as `Blob`
80
+ or `Buffer`. The `server` and `sdk` sub-generators set it themselves.
81
+ - `mcp.output.types` must point at **Zod schema** files, not plain types — tools need a runtime
82
+ schema for argument validation and `z.toJSONSchema`. It falls back to `server.output.types` (when
83
+ `server.zod`) or the `zod` sub-config's output.
84
+
85
+ ## Programmatic use
86
+
87
+ ```typescript
88
+ import { createTypescriptPlugin } from '@contractkit/plugin-typescript';
89
+
90
+ const plugin = createTypescriptPlugin({ types: { output: '{filename}.types.ts' } }, process.cwd());
91
+ ```
92
+
93
+ Prefer the default export when loading through `contractkit.config.json`; the factory is for
94
+ building the plugin in code (tests, custom build scripts).
95
+
96
+ ## Gotchas
97
+
98
+ - The four output sub-configs are independent. `zod: true` **inside** `server` is not the same thing
99
+ as the top-level `zod` sub-config: the first changes what `server.output.types` emits, the second
100
+ adds a separate standalone schema output.
101
+ - `validateResponses` silently does nothing useful without `zod: true`.
102
+ - Which statuses a router writes and which the SDK throws is decided by the contract, not by this
103
+ plugin — a `404(documented)` is the client's error contract, a bare `404:` is a status the service
104
+ produces. See the response-set helpers in `@contractkit/core`.
105
+
106
+ ## More
107
+
108
+ - README: https://github.com/MaroonedSoftware/contractkit/blob/main/packages/plugin-typescript/README.md
109
+ - Configuration: https://github.com/MaroonedSoftware/contractkit/blob/main/docs/config.md
110
+ - SDK generation and subclient grouping: https://github.com/MaroonedSoftware/contractkit/blob/main/docs/tooling.md
111
+ - Full usage guide: https://github.com/MaroonedSoftware/contractkit/blob/main/llms-full.txt
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.1",
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,7 +27,7 @@
27
27
  ".": "./dist/index.js"
28
28
  },
29
29
  "dependencies": {
30
- "@contractkit/core": "0.27.0"
30
+ "@contractkit/core": "0.28.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@repo/config-eslint": "0.3.1",
@@ -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
  }