Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/thin-yen-keeps-no-cents.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': minor
---

`CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts.

Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included.
96 changes: 89 additions & 7 deletions packages/spec/src/data/currency-precision-iso4217.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@

import { describe, expect, it } from 'vitest';
import { CurrencyConfigSchema, FieldSchema } from './field.zod';
import { ObjectSchema } from './object.zod';
import {
CURRENCY_FRACTION_DIGITS,
currencyFractionDigits,
Expand DownExpand Up@@ -57,15 +58,18 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon
});

it('THE noisy-shape guard: an untouched fixed-JPY config (defaulted precision) parses clean', () => {
// The baked default 2 "contradicts" JPY's 0 digits — but it was never
// authored, so the rule must not fire. This is the assertion that proves
// the pre-default anchoring; with a property-level `.default(2)` it goes
// red (measured in this card's reverse verification).
// The default 2 "contradicts" JPY's 0 digits — but it was never authored,
// so the rule must not fire. This is the assertion that proves the
// pre-default anchoring; with a property-level `.default(2)` it goes red
// (measured in this card's reverse verification). Since #11423 the default
// is also no longer MATERIALIZED on this combination (the schema would
// refuse it as authored — see the idempotency block below), so the parsed
// output omits `precision` rather than carrying 2.
const result = CurrencyConfigSchema.safeParse({
currencyMode: 'fixed', defaultCurrency: 'JPY',
});
expect(result.success).toBe(true);
expect(result.data!.precision).toBe(2);
expect(result.data!.precision).toBeUndefined();
});

it('dynamic currencyMode is out of reach by design (JPY + 2 + dynamic passes)', () => {
Expand DownExpand Up@@ -107,16 +111,22 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon

it('agreeing combos parse byte-identically to the `.default(2)` era', () => {
// Measured on origin/main (37b82ed5b) before this change — same shape
// order, same materialized default, byte for byte.
// order, same materialized default, byte for byte. The one #11423 flip is
// deliberately NOT in this battery: a bare fixed-JPY config now omits
// `precision` (the schema would refuse the materialized 2 as authored —
// pinned in the idempotency block below); every combination here either
// authored its precision or cannot be refused, so byte-identity holds.
const cases: Array<[Record<string, unknown>, string]> = [
[{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'],
[{ precision: 0, currencyMode: 'fixed', defaultCurrency: 'JPY' },
'{"precision":0,"currencyMode":"fixed","defaultCurrency":"JPY"}'],
[{ precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' },
'{"precision":3,"currencyMode":"fixed","defaultCurrency":"KWD"}'],
[{ currencyMode: 'fixed', defaultCurrency: 'USD' },
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'],
[{ currencyMode: 'fixed', defaultCurrency: 'JPY' },
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"JPY"}'],
'{"currencyMode":"fixed","defaultCurrency":"JPY"}'],
[{}, '{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}'],
];
for (const [input, expected] of cases) {
Expand All@@ -140,6 +150,78 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon
});
});

// [#11423] (maintainer ruling routed from #9689, 2026-08-24, idempotent
// materialization): the `.overwrite()` never materializes a default the schema
// itself would refuse as authored. Baking `precision: 2` onto a bare fixed
// zero-/three-digit-currency config (JPY/KRW/KWD class) made parse output
// self-rejecting on re-parse — `parse(parse(x))` threw for accepted x, and the
// re-parse chain is the mainline authoring path (`ObjectSchema.create()`
// returns parse output; `objectstack build`'s defineStack parses it again).
// Same one-conditional shape as the #9689 master_detail guard in field.zod.ts.
describe('#11423 — the materialized precision default is never one the schema itself refuses', () => {
it('a bare fixed-JPY config parses green and OMITS precision — parse(parse(x)) is idempotent', () => {
// The card's measured break: parse #1 baked `precision: 2`, parse #2
// rejected it at `currencyConfig.precision` ("currency JPY has 0 fraction
// digits; `precision: 2` contradicts it"). Absent is the honest spelling.
const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' });
expect(once.precision).toBeUndefined();
expect('precision' in once).toBe(false);
const again = CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once)));
expect(again.success).toBe(true);
expect(JSON.stringify(again.data)).toBe(JSON.stringify(once));
});

it('parse is IDEMPOTENT through the mainline create() → defineStack chain (the chain that carried the defect)', () => {
const field = FieldSchema.parse({
name: 'amount', label: 'Amount', type: 'currency',
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' },
});
expect(FieldSchema.safeParse(JSON.parse(JSON.stringify(field))).success).toBe(true);
const obj = ObjectSchema.create({
name: 'invoice', label: 'Invoice',
fields: { amount: { label: 'Amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } } },
});
expect(ObjectSchema.safeParse(obj).success).toBe(true);
});

it('an AUTHORED contradictory precision is still rejected with the named message (the guard narrows materialization, not the rule)', () => {
const result = CurrencyConfigSchema.safeParse({
precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY',
});
expect(result.success).toBe(false);
const issue = firstIssue(result)!;
expect(issue.code).toBe('custom');
expect(issue.path).toEqual(['precision']);
expect(issue.message).toContain('currency JPY has 0 fraction digits');
expect(issue.message).toContain('`precision: 2` contradicts it');
});

it('a bare fixed-USD config still materializes precision 2 byte-identically (the default keeps baking where it is legal — #7918 relocation intact)', () => {
expect(JSON.stringify(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'USD' })))
.toBe('{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}');
});

it('the whole refused class skips materialization — 0-digit (KRW) and 3-digit (KWD) fixed currencies omit precision and re-parse green', () => {
for (const code of ['KRW', 'KWD']) {
const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: code });
expect('precision' in once).toBe(false);
expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true);
}
});

it('combinations the superRefine cannot refuse keep materializing — dynamic mode and unknown fixed codes', () => {
// dynamic + JPY: no single currency to check against, baked 2 re-parses
// green (the superRefine only judges `fixed`); unknown fixed code: the
// digit table fails OPEN, so 2 is never refused.
expect(CurrencyConfigSchema.parse({ defaultCurrency: 'JPY' }).precision).toBe(2);
expect(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'BTC' }).precision).toBe(2);
for (const input of [{ defaultCurrency: 'JPY' }, { currencyMode: 'fixed', defaultCurrency: 'BTC' }]) {
const once = CurrencyConfigSchema.parse(input);
expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true);
}
});
});

describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no default)', () => {
const base = { name: 'amount', label: 'Amount', type: 'currency' as const };

Expand Down
49 changes: 39 additions & 10 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -288,23 +288,52 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({
if (contradiction !== undefined) {
ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction });
}
}).overwrite((config) => ({
}).overwrite((config) => {
// #7918 — the relocated `.default(2)`, applied AFTER the check above.
// `.overwrite()` rather than `.transform()` per the measured #6926 precedent
// (view.zod.ts `foldFormGroupsIntoSections`): it keeps this schema a
// `ZodObject` (a pipe has no `.extend` and answers shape introspection with
// an empty set), and checks run in attachment order, so the superRefine
// above always sees the pre-materialized value. Rebuilt in shape order so
// the output is byte-identical to the `.default(2)` era:
// `{precision, currencyMode, defaultCurrency}`, `precision` always a number.
// The one accepted cost, same as #6926's: the INFERRED output type still
// declares `precision?` even though a parsed config always carries it
// (ADR-0122 forbids hand-narrowing `CurrencyConfigParsed`); the runtime
// contract is the enforced one.
precision: config.precision ?? 2,
currencyMode: config.currencyMode,
defaultCurrency: config.defaultCurrency,
})));
// `{precision, currencyMode, defaultCurrency}`, `precision` always a number
// — except on the guarded combination below. The one accepted cost, same as
// #6926's: the INFERRED output type still declares `precision?` even though
// a parsed config normally carries it (ADR-0122 forbids hand-narrowing
// `CurrencyConfigParsed`); the runtime contract is the enforced one.
//
// #11423 (maintainer ruling on #9689, 2026-08-24, routed to this twin —
// 「The same principle prescribes the fix for the #7918 currency twin
// (#11423) — the spec seat should route it under this ruling.」): NEVER
// materialize a default the schema itself would refuse as authored. The
// superRefine above rejects an AUTHORED `precision: 2` on a fixed
// zero-/three-fraction-digit currency (JPY/KRW/KWD class), and the two
// spellings are indistinguishable to any later parse BY DESIGN — so baking
// `2` onto a bare fixed-JPY config made parse output self-rejecting on
// re-parse, and `ObjectSchema.create()` → `defineStack` re-parses on the
// MAINLINE app-build path (measured: `parse(parse(x))` threw at
// `currencyConfig.precision` for accepted x). A bare fixed config whose
// currency contradicts the default 2 therefore parses to output that OMITS
// `precision`: renderers already derive display width from the currency
// when the key is absent, and built artifacts stop carrying a value the
// schema itself refuses. Every other combination keeps byte-identity —
// `dynamic` mode and unknown codes (fail-open table) can never be refused,
// so they keep materializing. The #9689 master_detail `deleteBehavior`
// conditional in `FieldSchema`'s `.overwrite()` below is the worked
// precedent; #11423 is its recorded currency twin.
if (
config.precision === undefined &&
config.currencyMode === 'fixed' &&
currencyPrecisionContradiction(config.defaultCurrency, 2) !== undefined
) {
return config;
}
return {
precision: config.precision ?? 2,
currencyMode: config.currencyMode,
defaultCurrency: config.defaultCurrency,
};
}));

/**
* Currency Value Schema
Expand Down
Loading