@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/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
- luxon: coveredRoots.some(
791
- r => rootNeedsScalar(r, 'datetime') || rootNeedsScalar(r, 'date') || rootNeedsScalar(r, 'time') || rootNeedsScalar(r, 'interval'),
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: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
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: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
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
 
@@ -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);