@contractkit/plugin-typescript 0.33.3 → 0.34.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.
- package/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +26 -25
- package/CHANGELOG.md +285 -0
- package/dist/codegen-contract.d.ts +22 -6
- 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 +38 -4
- package/dist/codegen-revive.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +2 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +421 -202
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts +9 -0
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +14 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/llms.txt +14 -0
- package/package.json +3 -2
- package/src/codegen-contract.ts +84 -68
- package/src/codegen-mcp.ts +22 -11
- package/src/codegen-operation.ts +32 -19
- package/src/codegen-plain-types.ts +15 -8
- package/src/codegen-revive.ts +140 -20
- package/src/codegen-sdk.ts +322 -63
- package/src/index.ts +46 -4
- package/src/path-utils.ts +10 -0
- package/src/ts-render.ts +26 -0
- package/tests/codegen-contract.test.ts +87 -32
- package/tests/codegen-mcp.test.ts +23 -1
- package/tests/codegen-operation.test.ts +68 -8
- package/tests/codegen-plain-types.test.ts +13 -5
- package/tests/codegen-sdk.test.ts +265 -10
- package/tests/codegen-server.test.ts +43 -1
- package/tests/helpers.ts +7 -2
- package/tests/pipeline.test.ts +51 -4
- package/tests/ts-render.test.ts +29 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { relative, dirname } from 'node:path';
|
|
2
1
|
import type { ContractRootNode, ModelNode, FieldNode } from '@contractkit/core';
|
|
3
2
|
import { computeModelsWithOutput, collectExternalOutputRefs } from '@contractkit/core';
|
|
4
3
|
import type { ContractCodegenContext } from './codegen-contract.js';
|
|
@@ -10,10 +9,10 @@ import {
|
|
|
10
9
|
resolveImportPath,
|
|
11
10
|
rootNeedsScalar,
|
|
12
11
|
} from './codegen-contract.js';
|
|
13
|
-
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
12
|
+
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, sourceLink, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
14
13
|
import type { TsRenderTarget } from './ts-render.js';
|
|
15
14
|
import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
|
|
16
|
-
import { renderReviveFunctions, reviveFnName,
|
|
15
|
+
import { renderReviveFunctions, reviveFnName, coerceDeclsFor } from './codegen-revive.js';
|
|
17
16
|
|
|
18
17
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
19
18
|
|
|
@@ -52,6 +51,14 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
52
51
|
const needsDecimal = rootNeedsScalar(root, 'decimal') || (context?.emitRevivers && context.modelsWithDecimal ? root.models.some(m => context.modelsWithDecimal!.has(m.name)) : false);
|
|
53
52
|
if (needsDecimal) lines.push(DECIMAL_IMPORT);
|
|
54
53
|
|
|
54
|
+
// Likewise for the temporal scalars: `renderTsScalar` maps them to Luxon classes, which are a
|
|
55
|
+
// real runtime dependency of anyone holding one. `interval` is not among them — it renders as
|
|
56
|
+
// a string, since `_ZodInterval` transforms back to ISO on output.
|
|
57
|
+
const luxonImports: string[] = [];
|
|
58
|
+
if (rootNeedsScalar(root, 'date') || rootNeedsScalar(root, 'time') || rootNeedsScalar(root, 'datetime')) luxonImports.push('DateTime');
|
|
59
|
+
if (rootNeedsScalar(root, 'duration')) luxonImports.push('Duration');
|
|
60
|
+
if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
61
|
+
|
|
55
62
|
// Type-only imports for external references. A cross-file model carrying a decimal also
|
|
56
63
|
// contributes its reviver, which is a value and so needs a second, non-type import.
|
|
57
64
|
for (const ref of allExternalRefs) {
|
|
@@ -99,10 +106,11 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
99
106
|
lines.push(DECIMAL_CONFIG_LINE);
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
// Same rule as in `generateContract`:
|
|
109
|
+
// Same rule as in `generateContract`: a helper is emitted only if the revivers actually
|
|
103
110
|
// reference it, so the two cannot drift and trip `noUnusedLocals`.
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
const coerceDecls = coerceDeclsFor(bodyLines);
|
|
112
|
+
if (coerceDecls.length > 0) {
|
|
113
|
+
lines.push(...coerceDecls);
|
|
106
114
|
lines.push('');
|
|
107
115
|
}
|
|
108
116
|
lines.push(...bodyLines);
|
|
@@ -174,8 +182,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
174
182
|
for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
|
|
175
183
|
}
|
|
176
184
|
|
|
177
|
-
|
|
178
|
-
lines.push(` * generated from [${model.name}](file://./${relPath}#L${model.loc.line})`);
|
|
185
|
+
lines.push(` * generated from ${sourceLink(model.name, outPath, model.loc.file, model.loc.line)}`);
|
|
179
186
|
lines.push(' */');
|
|
180
187
|
return lines;
|
|
181
188
|
}
|
package/src/codegen-revive.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContractTypeNode, FieldNode, ModelNode } from '@contractkit/core';
|
|
1
|
+
import type { ContractTypeNode, FieldNode, ModelNode, ScalarTypeNode } from '@contractkit/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Emitters for the `reviveX` functions that rehydrate `decimal` fields in an SDK response.
|
|
@@ -19,27 +19,98 @@ import type { ContractTypeNode, FieldNode, ModelNode } from '@contractkit/core';
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
export interface ReviveCodegenOptions {
|
|
22
|
-
/** Models that carry a
|
|
22
|
+
/** Models that carry a revivable scalar, directly or transitively. Only these get a reviver. */
|
|
23
23
|
modelsWithDecimal: Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Which scalars need rehydrating from their wire form. Defaults to `decimal`, the only scalar
|
|
26
|
+
* whose runtime type currently differs from what `JSON.parse` produces.
|
|
27
|
+
*
|
|
28
|
+
* A set rather than a boolean because the question a reviver asks is not "does this reach a
|
|
29
|
+
* decimal" but "which conversion does this leaf need" — and the answer becomes plural as soon
|
|
30
|
+
* as a second scalar joins.
|
|
31
|
+
*/
|
|
32
|
+
revivableScalars?: ReadonlySet<ScalarTypeNode['name']>;
|
|
24
33
|
/** Models with an `Output` variant, which need a second reviver keyed by the output casing. */
|
|
25
34
|
modelsWithOutput?: Set<string>;
|
|
26
35
|
/** Every model in scope, for resolving discriminated-union members to their literal tag. */
|
|
27
36
|
modelMap?: Map<string, ModelNode>;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The per-file coercion helpers, keyed by the call prefix that identifies them in emitted text.
|
|
41
|
+
*
|
|
42
|
+
* A file gets only the helpers its revivers actually call, decided by scanning the emitted lines
|
|
43
|
+
* — the same text-derived idiom the reviver and type imports use, and for the same reason: a
|
|
44
|
+
* predicate computed separately can drift and leave an unused local behind.
|
|
45
|
+
*
|
|
46
|
+
* Each one takes the raw JSON value and throws a `TypeError` naming the path rather than
|
|
47
|
+
* returning something invalid, because a silently wrong `DateTime` surfaces much further from
|
|
48
|
+
* the cause than a throw at the boundary does.
|
|
49
|
+
*/
|
|
50
|
+
export const COERCE_DECLS: Record<string, string[]> = {
|
|
51
|
+
'__dec(': [
|
|
52
|
+
`const __dec = (v: unknown, path: string): Decimal => {`,
|
|
53
|
+
` if (typeof v !== 'string') {`,
|
|
54
|
+
` throw new TypeError(\`ContractKit: expected a decimal string at '\${path}', received \${typeof v} — decimals must be sent as quoted JSON strings.\`);`,
|
|
55
|
+
` }`,
|
|
56
|
+
` try {`,
|
|
57
|
+
` return new Decimal(v);`,
|
|
58
|
+
` } catch {`,
|
|
59
|
+
` throw new TypeError(\`ContractKit: '\${v}' at '\${path}' is not a valid decimal.\`);`,
|
|
60
|
+
` }`,
|
|
61
|
+
`};`,
|
|
62
|
+
],
|
|
63
|
+
'__dt(': [
|
|
64
|
+
`const __dt = (v: unknown, path: string): DateTime => {`,
|
|
65
|
+
` if (typeof v !== 'string') {`,
|
|
66
|
+
` throw new TypeError(\`ContractKit: expected an ISO 8601 string at '\${path}', received \${typeof v}.\`);`,
|
|
67
|
+
` }`,
|
|
68
|
+
` const d = DateTime.fromISO(v);`,
|
|
69
|
+
` if (!d.isValid) throw new TypeError(\`ContractKit: '\${v}' at '\${path}' is not a valid ISO 8601 datetime.\`);`,
|
|
70
|
+
` return d;`,
|
|
71
|
+
`};`,
|
|
72
|
+
],
|
|
73
|
+
'__dtf(': [
|
|
74
|
+
`const __dtf = (v: unknown, path: string, fmt: string): DateTime => {`,
|
|
75
|
+
` if (typeof v !== 'string') {`,
|
|
76
|
+
` throw new TypeError(\`ContractKit: expected a string at '\${path}' in format \${fmt}, received \${typeof v}.\`);`,
|
|
77
|
+
` }`,
|
|
78
|
+
` const d = DateTime.fromFormat(v, fmt);`,
|
|
79
|
+
` if (!d.isValid) throw new TypeError(\`ContractKit: '\${v}' at '\${path}' does not match format \${fmt}.\`);`,
|
|
80
|
+
` return d;`,
|
|
81
|
+
`};`,
|
|
82
|
+
],
|
|
83
|
+
'__dur(': [
|
|
84
|
+
`const __dur = (v: unknown, path: string): Duration => {`,
|
|
85
|
+
` if (typeof v !== 'string') {`,
|
|
86
|
+
` throw new TypeError(\`ContractKit: expected an ISO 8601 duration string at '\${path}', received \${typeof v}.\`);`,
|
|
87
|
+
` }`,
|
|
88
|
+
` const d = Duration.fromISO(v);`,
|
|
89
|
+
` if (!d.isValid) throw new TypeError(\`ContractKit: '\${v}' at '\${path}' is not a valid ISO 8601 duration.\`);`,
|
|
90
|
+
` return d;`,
|
|
91
|
+
`};`,
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Back-compat alias: the decimal helper alone, which several call sites still name directly. */
|
|
96
|
+
export const DECIMAL_COERCE_DECL = COERCE_DECLS['__dec(']!;
|
|
97
|
+
|
|
98
|
+
/** The helper declarations `lines` actually calls, in a stable order. */
|
|
99
|
+
export function coerceDeclsFor(lines: string[]): string[] {
|
|
100
|
+
const haystack = lines.join('\n');
|
|
101
|
+
return Object.entries(COERCE_DECLS)
|
|
102
|
+
.filter(([prefix]) => haystack.includes(prefix))
|
|
103
|
+
.flatMap(([, decl]) => decl);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Luxon classes referenced by the helpers `lines` calls, for the importing file to bring in. */
|
|
107
|
+
export function coerceLuxonImports(lines: string[]): string[] {
|
|
108
|
+
const haystack = lines.join('\n');
|
|
109
|
+
const needed = new Set<string>();
|
|
110
|
+
if (haystack.includes('__dt(') || haystack.includes('__dtf(')) needed.add('DateTime');
|
|
111
|
+
if (haystack.includes('__dur(')) needed.add('Duration');
|
|
112
|
+
return [...needed].sort();
|
|
113
|
+
}
|
|
43
114
|
|
|
44
115
|
/** `reviveInvoice` / `reviveInvoiceOutput`. */
|
|
45
116
|
export function reviveFnName(model: string, variant: 'base' | 'output' = 'base'): string {
|
|
@@ -52,11 +123,27 @@ function applyCase(name: string, caseTransform: 'camel' | 'snake' | 'pascal' | u
|
|
|
52
123
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
53
124
|
}
|
|
54
125
|
|
|
55
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Scalars whose runtime type differs from what `JSON.parse` produces, and which a reviver
|
|
128
|
+
* therefore has to rehydrate. The default of {@link ReviveCodegenOptions.revivableScalars}.
|
|
129
|
+
*
|
|
130
|
+
* `interval` is excluded: `_ZodInterval` ends in `.transform(v => v.toISO()!)`, so its inferred
|
|
131
|
+
* output type is already `string` and there is nothing to revive it to. Covering it means making
|
|
132
|
+
* that round-trip idempotent first, which the router's `isRevalidatable` also depends on.
|
|
133
|
+
*/
|
|
134
|
+
export const DEFAULT_REVIVABLE_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set([
|
|
135
|
+
'decimal',
|
|
136
|
+
'date',
|
|
137
|
+
'time',
|
|
138
|
+
'datetime',
|
|
139
|
+
'duration',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/** Whether a type reaches a revivable scalar, following refs through `modelsWithDecimal`. */
|
|
56
143
|
export function typeReachesDecimal(type: ContractTypeNode, opts: ReviveCodegenOptions): boolean {
|
|
57
144
|
switch (type.kind) {
|
|
58
145
|
case 'scalar':
|
|
59
|
-
return type.name
|
|
146
|
+
return (opts.revivableScalars ?? DEFAULT_REVIVABLE_SCALARS).has(type.name);
|
|
60
147
|
case 'ref':
|
|
61
148
|
return opts.modelsWithDecimal.has(type.name);
|
|
62
149
|
case 'array':
|
|
@@ -66,6 +153,10 @@ export function typeReachesDecimal(type: ContractTypeNode, opts: ReviveCodegenOp
|
|
|
66
153
|
case 'tuple':
|
|
67
154
|
return type.items.some(t => typeReachesDecimal(t, opts));
|
|
68
155
|
case 'record':
|
|
156
|
+
// Value only, deliberately unlike `typeHasScalar` in core, which also checks the key.
|
|
157
|
+
// That one answers "is this scalar mentioned", which decides imports; this one answers
|
|
158
|
+
// "is there a value to rehydrate", and a JSON object key is always a string — there is
|
|
159
|
+
// nothing at a key position for a reviver to convert.
|
|
69
160
|
return typeReachesDecimal(type.value, opts);
|
|
70
161
|
case 'union':
|
|
71
162
|
case 'discriminatedUnion':
|
|
@@ -78,6 +169,35 @@ export function typeReachesDecimal(type: ContractTypeNode, opts: ReviveCodegenOp
|
|
|
78
169
|
}
|
|
79
170
|
}
|
|
80
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The statement that rehydrates one scalar leaf, or nothing when the scalar needs no conversion.
|
|
174
|
+
*
|
|
175
|
+
* `date` and `time` carry their format on the node, and the reviver has to parse with the same
|
|
176
|
+
* format the schema validates against — which is why the helper takes it as an argument rather
|
|
177
|
+
* than baking one in. Defaults match `renderTsScalar`'s.
|
|
178
|
+
*
|
|
179
|
+
* `interval` is deliberately absent. `_ZodInterval` ends in `.transform(v => v.toISO()!)`, so its
|
|
180
|
+
* inferred output is a string and there is nothing to revive it to; covering it means making that
|
|
181
|
+
* round-trip idempotent first, which `isRevalidatable` in the router also depends on.
|
|
182
|
+
*/
|
|
183
|
+
function scalarCoercion(type: ScalarTypeNode, slot: string, path: string, opts: ReviveCodegenOptions): string[] {
|
|
184
|
+
if (!(opts.revivableScalars ?? DEFAULT_REVIVABLE_SCALARS).has(type.name)) return [];
|
|
185
|
+
switch (type.name) {
|
|
186
|
+
case 'decimal':
|
|
187
|
+
return [`${slot} = __dec(${slot}, '${path}');`];
|
|
188
|
+
case 'datetime':
|
|
189
|
+
return [`${slot} = __dt(${slot}, '${path}');`];
|
|
190
|
+
case 'duration':
|
|
191
|
+
return [`${slot} = __dur(${slot}, '${path}');`];
|
|
192
|
+
case 'date':
|
|
193
|
+
return [`${slot} = __dtf(${slot}, '${path}', '${type.format ?? 'yyyy-MM-dd'}');`];
|
|
194
|
+
case 'time':
|
|
195
|
+
return [`${slot} = __dtf(${slot}, '${path}', '${type.format ?? 'HH:mm:ss'}');`];
|
|
196
|
+
default:
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
81
201
|
/** Fresh local names, so nested loops in one function body cannot collide. */
|
|
82
202
|
class Scope {
|
|
83
203
|
private n = 0;
|
|
@@ -95,7 +215,7 @@ class Scope {
|
|
|
95
215
|
function emit(slot: string, type: ContractTypeNode, path: string, opts: ReviveCodegenOptions, scope: Scope, variant: 'base' | 'output'): string[] {
|
|
96
216
|
switch (type.kind) {
|
|
97
217
|
case 'scalar':
|
|
98
|
-
return type
|
|
218
|
+
return scalarCoercion(type, slot, path, opts);
|
|
99
219
|
|
|
100
220
|
case 'ref':
|
|
101
221
|
return opts.modelsWithDecimal.has(type.name) ? [`${reviveRefName(type.name, opts, variant)}(${slot} as never);`] : [];
|
|
@@ -278,7 +398,7 @@ function renderOne(model: ModelNode, opts: ReviveCodegenOptions, variant: 'base'
|
|
|
278
398
|
const body = emit('__v[0]', model.type, model.name, opts, scope, variant);
|
|
279
399
|
if (body.length === 0) return [];
|
|
280
400
|
return [
|
|
281
|
-
`/** Rehydrates every
|
|
401
|
+
`/** Rehydrates every wire-encoded scalar in a ${typeName} into its runtime type. Mutates and returns \`raw\`. */`,
|
|
282
402
|
`export function ${fnName}(raw: ${typeName}): ${typeName} {`,
|
|
283
403
|
` const __v = [raw] as unknown[];`,
|
|
284
404
|
...body.map(l => ` ${l}`),
|
|
@@ -294,7 +414,7 @@ function renderOne(model: ModelNode, opts: ReviveCodegenOptions, variant: 'base'
|
|
|
294
414
|
if (body.length === 0) return [];
|
|
295
415
|
|
|
296
416
|
return [
|
|
297
|
-
`/** Rehydrates every
|
|
417
|
+
`/** Rehydrates every wire-encoded scalar in a ${typeName} into its runtime type. Mutates and returns \`raw\`. */`,
|
|
298
418
|
`export function ${fnName}(raw: ${typeName}): ${typeName} {`,
|
|
299
419
|
` const ${obj} = raw as unknown as Record<string, unknown>;`,
|
|
300
420
|
...body.map(l => ` ${l}`),
|