@contractkit/plugin-typescript 0.33.3 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 decimal, directly or transitively. Only these get a reviver. */
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
- /** 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
- ];
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
- /** Whether a type reaches a decimal, following refs through `modelsWithDecimal`. */
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 === 'decimal';
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.name === 'decimal' ? [`${slot} = __dec(${slot}, '${path}');`] : [];
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 \`decimal\` in a ${typeName} from its wire string. Mutates and returns \`raw\`. */`,
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 \`decimal\` in a ${typeName} from its wire string. Mutates and returns \`raw\`. */`,
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}`),