@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.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +22 -20
- package/CHANGELOG.md +83 -0
- package/README.md +5 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/codegen-revive.d.ts +42 -0
- package/dist/codegen-revive.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +18 -6
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/decimal-runtime.d.ts +47 -0
- package/dist/decimal-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +538 -40
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/codegen-contract.ts +58 -3
- package/src/codegen-mcp.ts +5 -0
- package/src/codegen-operation.ts +23 -1
- package/src/codegen-plain-types.ts +42 -2
- package/src/codegen-revive.ts +304 -0
- package/src/codegen-sdk.ts +248 -37
- package/src/decimal-runtime.ts +50 -0
- package/src/index.ts +30 -3
- package/src/ts-render.ts +6 -0
- package/tests/codegen-contract.test.ts +124 -1
- package/tests/codegen-operation.test.ts +14 -0
- package/tests/codegen-sdk.test.ts +77 -6
- package/tests/pipeline.test.ts +24 -0
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
30
|
+
"@contractkit/core": "0.28.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@repo/config-
|
|
34
|
-
"@repo/config-
|
|
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",
|
package/src/codegen-contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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': {
|
package/src/codegen-mcp.ts
CHANGED
|
@@ -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';`);
|
package/src/codegen-operation.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|