@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.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +22 -20
- package/CHANGELOG.md +94 -0
- package/README.md +9 -4
- 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/llms.txt +111 -0
- package/package.json +2 -2
- 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/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
collectTransitiveModelRefs,
|
|
22
22
|
collectTypeRefs,
|
|
23
23
|
computeModelsWithCaseTransform,
|
|
24
|
+
computeModelsWithDecimal,
|
|
24
25
|
} from '@contractkit/core';
|
|
25
26
|
import {
|
|
26
27
|
generateSdk,
|
|
@@ -102,7 +103,8 @@ export interface SdkConfig {
|
|
|
102
103
|
* write-once: the files are created only when absent and are never overwritten or
|
|
103
104
|
* cleaned up on later builds, so any edits you make to them are preserved.
|
|
104
105
|
* Dependency ranges are derived from the contracts (always `zod` when `zod: true`;
|
|
105
|
-
* `luxon` when any covered model uses a date/time/datetime/interval scalar
|
|
106
|
+
* `luxon` when any covered model uses a date/time/datetime/duration/interval scalar;
|
|
107
|
+
* `decimal.js` when any uses a decimal).
|
|
106
108
|
*/
|
|
107
109
|
scaffold?: boolean;
|
|
108
110
|
}
|
|
@@ -463,6 +465,9 @@ function collectSdkOutput(
|
|
|
463
465
|
|
|
464
466
|
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
465
467
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
468
|
+
// Computed across every contract root, not per file: one decimal below a model taints it, and
|
|
469
|
+
// the reference that reaches it may live in another .ck file entirely.
|
|
470
|
+
const modelsWithDecimal = computeModelsWithDecimal(inputs.contractRoots.flatMap(r => r.models));
|
|
466
471
|
const modelMap = buildModelMap(inputs.contractRoots);
|
|
467
472
|
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
468
473
|
const ckCommonRoot = commonDir(allFiles, rootDir);
|
|
@@ -501,6 +506,9 @@ function collectSdkOutput(
|
|
|
501
506
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
502
507
|
modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
|
|
503
508
|
modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
|
|
509
|
+
// Not covered by `root`: adding a decimal to a model in a *different* .ck file changes
|
|
510
|
+
// this file's revivers with no change to `root` or the config.
|
|
511
|
+
modelsWithDecimal: sliceModelSet(refs, ownNames, modelsWithDecimal),
|
|
504
512
|
sdkOptionsPath,
|
|
505
513
|
sub: subConfigKey,
|
|
506
514
|
});
|
|
@@ -509,12 +517,16 @@ function collectSdkOutput(
|
|
|
509
517
|
fingerprint,
|
|
510
518
|
render: () => {
|
|
511
519
|
let content: string;
|
|
520
|
+
// `emitRevivers` is set for SDK type files only: a server handler receives decimals
|
|
521
|
+
// already parsed by `_ZodDecimal`, so it has nothing to rehydrate.
|
|
512
522
|
if (config.zod) {
|
|
513
523
|
content = generateContract(ast, {
|
|
514
524
|
modelOutPaths: sdkModelOutPaths,
|
|
515
525
|
currentOutPath: typeOutPath,
|
|
516
526
|
modelsWithInput,
|
|
517
527
|
modelsWithOutput,
|
|
528
|
+
modelsWithDecimal,
|
|
529
|
+
emitRevivers: true,
|
|
518
530
|
});
|
|
519
531
|
} else {
|
|
520
532
|
let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
|
|
@@ -524,6 +536,8 @@ function collectSdkOutput(
|
|
|
524
536
|
currentOutPath: typeOutPath,
|
|
525
537
|
modelsWithInput,
|
|
526
538
|
modelsWithOutput,
|
|
539
|
+
modelsWithDecimal,
|
|
540
|
+
emitRevivers: true,
|
|
527
541
|
jsonValueImportPath: rel,
|
|
528
542
|
});
|
|
529
543
|
}
|
|
@@ -572,6 +586,7 @@ function collectSdkOutput(
|
|
|
572
586
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
573
587
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
574
588
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
589
|
+
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
575
590
|
sdkOptionsPath,
|
|
576
591
|
className,
|
|
577
592
|
includeInternal: config.includeInternal ?? false,
|
|
@@ -590,6 +605,8 @@ function collectSdkOutput(
|
|
|
590
605
|
sdkOptionsPath,
|
|
591
606
|
modelsWithInput,
|
|
592
607
|
modelsWithOutput,
|
|
608
|
+
modelsWithDecimal,
|
|
609
|
+
modelMap,
|
|
593
610
|
includeInternal: config.includeInternal,
|
|
594
611
|
clientClassName: className,
|
|
595
612
|
}),
|
|
@@ -612,6 +629,7 @@ function collectSdkOutput(
|
|
|
612
629
|
outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
613
630
|
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
614
631
|
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
632
|
+
modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
|
|
615
633
|
sdkOptionsPath,
|
|
616
634
|
includeInternal: config.includeInternal ?? false,
|
|
617
635
|
sub: subConfigKey,
|
|
@@ -629,6 +647,8 @@ function collectSdkOutput(
|
|
|
629
647
|
sdkOptionsPath,
|
|
630
648
|
modelsWithInput,
|
|
631
649
|
modelsWithOutput,
|
|
650
|
+
modelsWithDecimal,
|
|
651
|
+
modelMap,
|
|
632
652
|
includeInternal: config.includeInternal,
|
|
633
653
|
}),
|
|
634
654
|
},
|
|
@@ -710,6 +730,7 @@ function collectSdkOutput(
|
|
|
710
730
|
outPathSlice: sliceOutPathMap(allInlineRefs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
|
|
711
731
|
modelsWithInput: sliceModelSet(allInlineRefs, new Set(), modelsWithInput),
|
|
712
732
|
modelsWithOutput: sliceModelSet(allInlineRefs, new Set(), modelsWithOutput),
|
|
733
|
+
modelsWithDecimal: sliceModelSet(allInlineRefs, new Set(), modelsWithDecimal),
|
|
713
734
|
sdkOptionsPath,
|
|
714
735
|
includeInternal: config.includeInternal ?? false,
|
|
715
736
|
sub: subConfigKey,
|
|
@@ -724,6 +745,8 @@ function collectSdkOutput(
|
|
|
724
745
|
sdkOptionsPath,
|
|
725
746
|
modelsWithInput,
|
|
726
747
|
modelsWithOutput,
|
|
748
|
+
modelsWithDecimal,
|
|
749
|
+
modelMap,
|
|
727
750
|
includeInternal: config.includeInternal,
|
|
728
751
|
},
|
|
729
752
|
}));
|
|
@@ -787,9 +810,13 @@ function collectSdkOutput(
|
|
|
787
810
|
const coveredRoots = sdkContractEntries.map(e => e.ast);
|
|
788
811
|
const deps: SdkScaffoldDeps = {
|
|
789
812
|
zod: !!config.zod,
|
|
790
|
-
|
|
791
|
-
|
|
813
|
+
// `duration` belongs here too: `generateContract` imports `Duration` from luxon for it,
|
|
814
|
+
// so a contract whose only temporal scalar is a duration used to scaffold a package.json
|
|
815
|
+
// with no luxon dependency and fail to compile.
|
|
816
|
+
luxon: coveredRoots.some(r =>
|
|
817
|
+
(['datetime', 'date', 'time', 'duration', 'interval'] as const).some(name => rootNeedsScalar(r, name)),
|
|
792
818
|
),
|
|
819
|
+
decimal: coveredRoots.some(r => rootNeedsScalar(r, 'decimal')),
|
|
793
820
|
};
|
|
794
821
|
globalFiles.push({
|
|
795
822
|
relativePath: join(sdkBase, 'package.json'),
|
package/src/ts-render.ts
CHANGED
|
@@ -96,6 +96,12 @@ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): s
|
|
|
96
96
|
return 'number';
|
|
97
97
|
case 'bigint':
|
|
98
98
|
return 'bigint';
|
|
99
|
+
case 'decimal':
|
|
100
|
+
// The one scalar whose wire view and server view agree — see the note on this
|
|
101
|
+
// function and on `serverTsScalar`. A `decimal` travels as a quoted string, and both
|
|
102
|
+
// the router (via `_ZodDecimal`) and the SDK (via the generated `reviveX` functions)
|
|
103
|
+
// hand the developer a real `Decimal`, so there is no `target` split to make.
|
|
104
|
+
return 'Decimal';
|
|
99
105
|
case 'boolean':
|
|
100
106
|
return 'boolean';
|
|
101
107
|
case 'date':
|
|
@@ -27,7 +27,7 @@ describe('renderType', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it('throws on an unmapped scalar name', () => {
|
|
30
|
-
expect(() => renderType({ kind: 'scalar', name: '
|
|
30
|
+
expect(() => renderType({ kind: 'scalar', name: 'quaternion' } as any)).toThrow(/unmapped scalar 'quaternion'/);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('renders z.string() with min/max', () => {
|
|
@@ -101,6 +101,36 @@ describe('renderType', () => {
|
|
|
101
101
|
expect(result).toBe(`z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, z.bigint().min(0n).max(100n))`);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('renders bare _ZodDecimal for an unconstrained decimal', () => {
|
|
105
|
+
expect(renderType(scalarType('decimal'))).toBe('_ZodDecimal');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders a scale refinement on decimal', () => {
|
|
109
|
+
expect(renderType(scalarType('decimal', { scale: 2 }))).toBe(
|
|
110
|
+
`_ZodDecimal.refine((v) => v.decimalPlaces() <= 2, { message: 'Must be at most 2 decimal places' })`,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('compares decimal bounds as exact strings, never as floats', () => {
|
|
115
|
+
// `0.01` through `Number()` is the precision loss this scalar exists to prevent, so the
|
|
116
|
+
// bound is emitted as a quoted string for decimal.js to parse.
|
|
117
|
+
const result = renderType(scalarType('decimal', { min: '0.01', max: '999999.99' }));
|
|
118
|
+
expect(result).toBe(
|
|
119
|
+
`_ZodDecimal.refine((v) => v.gte('0.01') && v.lte('999999.99'), { message: 'Must be at least 0.01, at most 999999.99' })`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders scale and bounds together', () => {
|
|
124
|
+
const result = renderType(scalarType('decimal', { min: '0', scale: 2 }));
|
|
125
|
+
expect(result).toBe(
|
|
126
|
+
`_ZodDecimal.refine((v) => v.decimalPlaces() <= 2 && v.gte('0'), { message: 'Must be at most 2 decimal places, at least 0' })`,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('singularises the scale message at 1', () => {
|
|
131
|
+
expect(renderType(scalarType('decimal', { scale: 1 }))).toContain(`message: 'Must be at most 1 decimal place'`);
|
|
132
|
+
});
|
|
133
|
+
|
|
104
134
|
it('renders z.boolean() with string coercion preprocess', () => {
|
|
105
135
|
expect(renderType(scalarType('boolean'))).toBe(`z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`);
|
|
106
136
|
});
|
|
@@ -329,6 +359,99 @@ describe('generateContract', () => {
|
|
|
329
359
|
);
|
|
330
360
|
});
|
|
331
361
|
|
|
362
|
+
it('emits the decimal.js import and _ZodDecimal helper when a decimal field is present', () => {
|
|
363
|
+
const root = contractRoot([model('M', [field('amount', scalarType('decimal'))])]);
|
|
364
|
+
const output = generateContract(root);
|
|
365
|
+
// Named, not default: under NodeNext the default export resolves to decimal.js's
|
|
366
|
+
// namespace declaration and the generated SDK fails to compile.
|
|
367
|
+
expect(output).toContain(`import { Decimal } from 'decimal.js';`);
|
|
368
|
+
expect(output).toContain(`Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });`);
|
|
369
|
+
expect(output).toContain(`const _ZodDecimal = z.preprocess(`);
|
|
370
|
+
// No output `.transform()` — `isRevalidatable` treats every scalar as idempotent under
|
|
371
|
+
// re-parse, which `server.validateResponses` depends on.
|
|
372
|
+
expect(output).not.toContain('_ZodDecimal = z.preprocess(...).transform');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('omits the decimal runtime entirely when no decimal field is present', () => {
|
|
376
|
+
const root = contractRoot([model('M', [field('n', scalarType('number'))])]);
|
|
377
|
+
const output = generateContract(root);
|
|
378
|
+
expect(output).not.toContain('decimal.js');
|
|
379
|
+
expect(output).not.toContain('_ZodDecimal');
|
|
380
|
+
expect(output).not.toContain('Decimal.set');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('emits revivers only when asked, and only for decimal-carrying models', () => {
|
|
384
|
+
const root = contractRoot([
|
|
385
|
+
model('Money', [field('amount', scalarType('decimal'))]),
|
|
386
|
+
model('Plain', [field('name', scalarType('string'))]),
|
|
387
|
+
]);
|
|
388
|
+
const ctx = { modelOutPaths: new Map(), currentOutPath: '/out/t.ts' };
|
|
389
|
+
|
|
390
|
+
// Server-side schemas get decimals already parsed by `_ZodDecimal`, so no revivers.
|
|
391
|
+
const withoutRevivers = generateContract(root, ctx);
|
|
392
|
+
expect(withoutRevivers).not.toContain('reviveMoney');
|
|
393
|
+
expect(withoutRevivers).not.toContain('__dec');
|
|
394
|
+
|
|
395
|
+
const withRevivers = generateContract(root, { ...ctx, modelsWithDecimal: new Set(['Money']), emitRevivers: true });
|
|
396
|
+
expect(withRevivers).toContain('export function reviveMoney(raw: Money): Money {');
|
|
397
|
+
expect(withRevivers).toContain(`__o0["amount"] = __dec(__o0["amount"], 'Money.amount');`);
|
|
398
|
+
expect(withRevivers).not.toContain('revivePlain');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('guards an optional or nullable decimal instead of coercing null', () => {
|
|
402
|
+
const root = contractRoot([
|
|
403
|
+
model('M', [
|
|
404
|
+
field('a', scalarType('decimal'), { optional: true }),
|
|
405
|
+
field('b', unionType(scalarType('decimal'), scalarType('null'))),
|
|
406
|
+
]),
|
|
407
|
+
]);
|
|
408
|
+
const out = generateContract(root, {
|
|
409
|
+
modelOutPaths: new Map(),
|
|
410
|
+
currentOutPath: '/out/t.ts',
|
|
411
|
+
modelsWithDecimal: new Set(['M']),
|
|
412
|
+
emitRevivers: true,
|
|
413
|
+
});
|
|
414
|
+
expect(out).toContain(`if (__o0["a"] != null) {`);
|
|
415
|
+
expect(out).toContain(`if (__o0["b"] != null) {`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('keys the Output reviver by the output casing while children stay camel', () => {
|
|
419
|
+
const root = contractRoot([
|
|
420
|
+
model('Money', [field('amount', scalarType('decimal'))]),
|
|
421
|
+
model('Snake', [field('grossPay', scalarType('decimal')), field('childRef', refType('Money'))], { outputCase: 'snake' }),
|
|
422
|
+
]);
|
|
423
|
+
const out = generateContract(root, {
|
|
424
|
+
modelOutPaths: new Map(),
|
|
425
|
+
currentOutPath: '/out/t.ts',
|
|
426
|
+
modelsWithDecimal: new Set(['Money', 'Snake']),
|
|
427
|
+
emitRevivers: true,
|
|
428
|
+
});
|
|
429
|
+
expect(out).toContain('export function reviveSnakeOutput(raw: SnakeOutput): SnakeOutput {');
|
|
430
|
+
expect(out).toContain(`__o0["gross_pay"] = __dec(__o0["gross_pay"], 'Snake.gross_pay');`);
|
|
431
|
+
// `computeModelsWithOutput` propagates referrer→referenced, so Money is NOT transformed
|
|
432
|
+
// and keeps its camelCase keys — it must use the base reviver, not an Output one.
|
|
433
|
+
expect(out).toContain(`reviveMoney(__o0["child_ref"] as never);`);
|
|
434
|
+
expect(out).not.toContain('reviveMoneyOutput');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('recurses through a self-referential model without looping', () => {
|
|
438
|
+
const root = contractRoot([
|
|
439
|
+
model('Category', [field('subtotal', scalarType('decimal')), field('children', arrayType(refType('Category')))]),
|
|
440
|
+
]);
|
|
441
|
+
const out = generateContract(root, {
|
|
442
|
+
modelOutPaths: new Map(),
|
|
443
|
+
currentOutPath: '/out/t.ts',
|
|
444
|
+
modelsWithDecimal: new Set(['Category']),
|
|
445
|
+
emitRevivers: true,
|
|
446
|
+
});
|
|
447
|
+
expect(out).toContain('reviveCategory(__a1[__i2] as never);');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('detects a decimal nested in an array', () => {
|
|
451
|
+
const root = contractRoot([model('M', [field('amounts', arrayType(scalarType('decimal')))])]);
|
|
452
|
+
expect(generateContract(root)).toContain(`import { Decimal } from 'decimal.js';`);
|
|
453
|
+
});
|
|
454
|
+
|
|
332
455
|
it('detects DateTime in nested array types', () => {
|
|
333
456
|
const root = contractRoot([model('M', [field('d', arrayType(scalarType('datetime')))])]);
|
|
334
457
|
const output = generateContract(root);
|
|
@@ -1095,6 +1095,20 @@ describe('generateOperation', () => {
|
|
|
1095
1095
|
expect(generateOp(respRoot('User'), V)).toContain('ctx.body = await parseAndValidate(result, User, 500);');
|
|
1096
1096
|
});
|
|
1097
1097
|
|
|
1098
|
+
it('re-validates a decimal body, which `isRevalidatable` assumes is idempotent', () => {
|
|
1099
|
+
// `isRevalidatable` returns true for every scalar on the grounds that scalars are
|
|
1100
|
+
// idempotent under re-parse. `_ZodDecimal` satisfies that only because it has no output
|
|
1101
|
+
// `.transform()` — preprocess passes an already-`Decimal` value straight through. If it
|
|
1102
|
+
// were modelled on `_ZodInterval`, which does transform, this would 500 at runtime.
|
|
1103
|
+
const root = respRoot(inlineObjectType([field('amount', scalarType('decimal', { scale: 2 }))]));
|
|
1104
|
+
const output = generateOp(root, V);
|
|
1105
|
+
expect(output).toContain('await parseAndValidate(result,');
|
|
1106
|
+
expect(output).toContain('_ZodDecimal');
|
|
1107
|
+
expect(output).not.toMatch(/_ZodDecimal[^\n]*\.transform\(/);
|
|
1108
|
+
// The helper references `Decimal`, so the import has to come with it.
|
|
1109
|
+
expect(output).toContain(`import { Decimal } from 'decimal.js';`);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1098
1112
|
// A service returning a shape its own contract rejects is a server fault, not a client one.
|
|
1099
1113
|
// 500 also diverts the field-level detail to `internalDetails`, keeping it off the wire.
|
|
1100
1114
|
it('throws 500, not the parseAndValidate default of 400', () => {
|
|
@@ -1294,7 +1294,7 @@ describe('renderTsType', () => {
|
|
|
1294
1294
|
});
|
|
1295
1295
|
|
|
1296
1296
|
it('throws on an unmapped scalar name', () => {
|
|
1297
|
-
expect(() => renderTsType({ kind: 'scalar', name: '
|
|
1297
|
+
expect(() => renderTsType({ kind: 'scalar', name: 'quaternion' } as any)).toThrow(/unmapped scalar 'quaternion'/);
|
|
1298
1298
|
});
|
|
1299
1299
|
});
|
|
1300
1300
|
|
|
@@ -1857,9 +1857,72 @@ describe('generateAreaClient — <Area>Client emission', () => {
|
|
|
1857
1857
|
});
|
|
1858
1858
|
});
|
|
1859
1859
|
|
|
1860
|
+
describe('decimal rehydration', () => {
|
|
1861
|
+
const withDecimal = (extra: Partial<Parameters<typeof generateSdk>[1]> = {}) => ({
|
|
1862
|
+
modelsWithDecimal: new Set(['Invoice']),
|
|
1863
|
+
modelOutPaths: new Map([['Invoice', '/out/types/models.ts']]),
|
|
1864
|
+
outPath: '/out/clients/inv.client.ts',
|
|
1865
|
+
sdkOptionsPath: '/out/sdk-options.ts',
|
|
1866
|
+
...extra,
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it('wraps a model-ref response body in its reviver', () => {
|
|
1870
|
+
const root = opRoot([opRoute('/invoices/{id}', [opOperation('get', { responses: [opResponse(200, 'Invoice')] })])], 'inv.op');
|
|
1871
|
+
const out = generateSdk(root, withDecimal());
|
|
1872
|
+
expect(out).toContain('return reviveInvoice(await parseJson<Invoice>(result));');
|
|
1873
|
+
// The reviver is a value, so it needs a second import beside the `import type`.
|
|
1874
|
+
expect(out).toContain(`import { reviveInvoice } from '../types/models.js';`);
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
it('maps over an array body rather than emitting a wrapper', () => {
|
|
1878
|
+
const root = opRoot([opRoute('/invoices', [opOperation('get', { responses: [opResponse(200, 'array(Invoice)')] })])], 'inv.op');
|
|
1879
|
+
const out = generateSdk(root, withDecimal());
|
|
1880
|
+
// Safe because the reviver returns the object it mutated.
|
|
1881
|
+
expect(out).toContain('return (await parseJson<Invoice[]>(result)).map(reviveInvoice);');
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
it('emits a local wrapper for a body with no reviveX of its own', () => {
|
|
1885
|
+
const bodyType = inlineObjectType([field('grand', scalarType('decimal', { scale: 2 }))]);
|
|
1886
|
+
const root = opRoot([opRoute('/totals', [opOperation('get', { sdk: 'getTotals', responses: [opResponse(200, bodyType)] })])], 'tot.op');
|
|
1887
|
+
const out = generateSdk(root, withDecimal({ modelsWithDecimal: new Set(['Invoice']) }));
|
|
1888
|
+
expect(out).toMatch(/function __reviveGetTotals200\(/);
|
|
1889
|
+
// The wrapper calls `__dec`, which is file-local to the types module, so the client file
|
|
1890
|
+
// needs its own copy plus the decimal.js import and global config.
|
|
1891
|
+
expect(out).toContain(`import { Decimal } from 'decimal.js';`);
|
|
1892
|
+
expect(out).toContain('Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });');
|
|
1893
|
+
expect(out).toContain('const __dec = (v: unknown, path: string): Decimal =>');
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
it('picks the Output reviver when the model has an Output variant', () => {
|
|
1897
|
+
const root = opRoot([opRoute('/invoices/{id}', [opOperation('get', { responses: [opResponse(200, 'Invoice')] })])], 'inv.op');
|
|
1898
|
+
const out = generateSdk(root, withDecimal({ modelsWithOutput: new Set(['Invoice']) }));
|
|
1899
|
+
expect(out).toContain('reviveInvoiceOutput(');
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
it('leaves a decimal-free SDK byte-identical', () => {
|
|
1903
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User')] })])], 'users.op');
|
|
1904
|
+
const base = generateSdk(root, { modelOutPaths: new Map([['User', '/out/types/models.ts']]), outPath: '/out/clients/u.client.ts' });
|
|
1905
|
+
const withEmptySet = generateSdk(root, {
|
|
1906
|
+
modelOutPaths: new Map([['User', '/out/types/models.ts']]),
|
|
1907
|
+
outPath: '/out/clients/u.client.ts',
|
|
1908
|
+
modelsWithDecimal: new Set(),
|
|
1909
|
+
});
|
|
1910
|
+
expect(withEmptySet).toBe(base);
|
|
1911
|
+
expect(base).not.toContain('revive');
|
|
1912
|
+
expect(base).not.toContain('decimal.js');
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
it('does not revive a body whose model carries no decimal', () => {
|
|
1916
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User')] })])], 'users.op');
|
|
1917
|
+
const out = generateSdk(root, withDecimal({ modelOutPaths: new Map([['User', '/out/types/models.ts']]) }));
|
|
1918
|
+
expect(out).toContain('return await parseJson<User>(result);');
|
|
1919
|
+
expect(out).not.toContain('revive');
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1860
1923
|
describe('generateSdkPackageJson', () => {
|
|
1861
1924
|
it('emits a valid package.json with the given name and standard fields', () => {
|
|
1862
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'my-sdk', deps: { zod: false, luxon: false } }));
|
|
1925
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'my-sdk', deps: { zod: false, luxon: false, decimal: false } }));
|
|
1863
1926
|
expect(pkg.name).toBe('my-sdk');
|
|
1864
1927
|
expect(pkg.type).toBe('module');
|
|
1865
1928
|
expect(pkg.exports['.'].types).toBe('./dist/index.d.ts');
|
|
@@ -1868,25 +1931,33 @@ describe('generateSdkPackageJson', () => {
|
|
|
1868
1931
|
});
|
|
1869
1932
|
|
|
1870
1933
|
it('omits the dependencies block entirely when neither zod nor luxon is used', () => {
|
|
1871
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }));
|
|
1934
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: false } }));
|
|
1872
1935
|
expect(pkg.dependencies).toBeUndefined();
|
|
1873
1936
|
expect(pkg.devDependencies['@types/luxon']).toBeUndefined();
|
|
1874
1937
|
});
|
|
1875
1938
|
|
|
1876
1939
|
it('adds zod as a runtime dependency when zod output is enabled', () => {
|
|
1877
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: true, luxon: false } }));
|
|
1940
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: true, luxon: false, decimal: false } }));
|
|
1878
1941
|
expect(pkg.dependencies.zod).toBeDefined();
|
|
1879
1942
|
expect(pkg.dependencies.luxon).toBeUndefined();
|
|
1880
1943
|
});
|
|
1881
1944
|
|
|
1882
1945
|
it('adds luxon (runtime) and @types/luxon (dev) when a date/time scalar is used', () => {
|
|
1883
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: true } }));
|
|
1946
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: true, decimal: false } }));
|
|
1884
1947
|
expect(pkg.dependencies.luxon).toBeDefined();
|
|
1885
1948
|
expect(pkg.devDependencies['@types/luxon']).toBeDefined();
|
|
1886
1949
|
});
|
|
1887
1950
|
|
|
1951
|
+
it('adds decimal.js, with no @types half, when a decimal scalar is used', () => {
|
|
1952
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: true } }));
|
|
1953
|
+
expect(pkg.dependencies['decimal.js']).toBeDefined();
|
|
1954
|
+
// decimal.js ships its own declarations, unlike luxon.
|
|
1955
|
+
expect(pkg.devDependencies['@types/decimal.js']).toBeUndefined();
|
|
1956
|
+
expect(pkg.dependencies.luxon).toBeUndefined();
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1888
1959
|
it('ends with a trailing newline', () => {
|
|
1889
|
-
expect(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }).endsWith('}\n')).toBe(true);
|
|
1960
|
+
expect(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: false } }).endsWith('}\n')).toBe(true);
|
|
1890
1961
|
});
|
|
1891
1962
|
});
|
|
1892
1963
|
|
package/tests/pipeline.test.ts
CHANGED
|
@@ -33,6 +33,30 @@ describe('Contract pipeline (source -> parse -> codegen)', () => {
|
|
|
33
33
|
expect(output).toContain(`active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean()).default(true)`);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('compiles a decimal contract end to end, keeping bounds as exact strings', () => {
|
|
37
|
+
// Exercises the real parse path rather than a hand-built AST: `buildScalarWithModifiers`
|
|
38
|
+
// has to keep `0.01` as a string instead of routing it through `Number()`.
|
|
39
|
+
const { output, root, diag } = compileContractSource(`
|
|
40
|
+
contract Payslip: {
|
|
41
|
+
gross: decimal(min=0.01, max=999999.99, scale=2)
|
|
42
|
+
rate: decimal
|
|
43
|
+
}
|
|
44
|
+
`);
|
|
45
|
+
expect(diag.hasErrors()).toBe(false);
|
|
46
|
+
|
|
47
|
+
const gross = root.models[0]!.fields[0]!.type as { min: unknown; max: unknown; scale: unknown };
|
|
48
|
+
expect(gross.min).toBe('0.01');
|
|
49
|
+
expect(gross.max).toBe('999999.99');
|
|
50
|
+
expect(gross.scale).toBe(2);
|
|
51
|
+
|
|
52
|
+
expect(output).toContain("import { Decimal } from 'decimal.js';");
|
|
53
|
+
expect(output).toContain('Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });');
|
|
54
|
+
expect(output).toContain(
|
|
55
|
+
`gross: _ZodDecimal.refine((v) => v.decimalPlaces() <= 2 && v.gte('0.01') && v.lte('999999.99'), { message: 'Must be at most 2 decimal places, at least 0.01, at most 999999.99' })`,
|
|
56
|
+
);
|
|
57
|
+
expect(output).toContain('rate: _ZodDecimal');
|
|
58
|
+
});
|
|
59
|
+
|
|
36
60
|
it('compiles a contract with visibility to three-schema pattern', () => {
|
|
37
61
|
const { output, diag } = compileContractSource(VISIBILITY_CONTRACT);
|
|
38
62
|
expect(diag.hasErrors()).toBe(false);
|