diff --git a/.changeset/30648-currency-cldr-fraction-digits-spec.md b/.changeset/30648-currency-cldr-fraction-digits-spec.md new file mode 100644 index 00000000000..d765b3e6afd --- /dev/null +++ b/.changeset/30648-currency-cldr-fraction-digits-spec.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): a currency amount's decimal places are named as its currency's CLDR fraction digits, not its ISO 4217 minor unit + +Clause-②: no +Metadata-protocol: none + +The currency `scale` refusal, the inline grid column's currency `scale` refusal, the `currencyConfig` decimal-places prescription, the `precision` and `scale` describes and the ADR-0087 upgrade-guide entries said "the currency's ISO 4217 minor unit". The platform's digit source is CLDR's fraction digits: `CURRENCY_FRACTION_DIGITS` is a CLDR `currencyData` snapshot, and every display face asks `Intl`, which reads the same data. For sixteen codes the two differ and CLDR's count applies: IQD has 0 (ISO 4217: 3), and AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP and YER have 0 (ISO 4217: 2). + +Words only. Nothing is newly accepted or refused, and the remedy is unchanged: delete `scale` on a `currency` field or column. The refusal now reads "the currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the amount displays". A check that matches the old text "ISO 4217 minor unit" in the message no longer matches. diff --git a/.changeset/30648-currency-cldr-fraction-digits-ui-fields.md b/.changeset/30648-currency-cldr-fraction-digits-ui-fields.md new file mode 100644 index 00000000000..73ba50c10ad --- /dev/null +++ b/.changeset/30648-currency-cldr-fraction-digits-ui-fields.md @@ -0,0 +1,10 @@ +--- +'@object-ui/fields': patch +--- + +docs(fields): `currencyFractionDigits` is documented as CLDR's fraction digits, not ISO 4217's + +Clause-②: no +Metadata-protocol: none + +The published declaration of `currencyFractionDigits` said it returns "the number of fraction digits ISO 4217 gives" a currency. It asks `Intl`, which reads CLDR's `currencyData`, so it now says CLDR. For sixteen codes the two differ (IQD 0 where ISO 4217 says 3, and fifteen codes 0 where ISO 4217 says 2), and the function has always answered CLDR's count. Words only: no return value changes. diff --git a/.changeset/30648-currency-cldr-fraction-digits-ui-plugin-form.md b/.changeset/30648-currency-cldr-fraction-digits-ui-plugin-form.md new file mode 100644 index 00000000000..08d44e43123 --- /dev/null +++ b/.changeset/30648-currency-cldr-fraction-digits-ui-plugin-form.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-form': patch +--- + +fix(plugin-form): the warning for a `scale` on a currency inline grid column names the currency's CLDR fraction digits + +Clause-②: no +Metadata-protocol: none + +The console warning for an inline grid column that renders as `currency` and carries `scale` repeats the spec's refusal, which now says "the currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the cell displays the amount" instead of naming the ISO 4217 minor unit. Words only: the same columns are reported, and the column still renders at its currency's digits. diff --git a/.changeset/30648-currency-cldr-fraction-digits-ui-types.md b/.changeset/30648-currency-cldr-fraction-digits-ui-types.md new file mode 100644 index 00000000000..e69bc240933 --- /dev/null +++ b/.changeset/30648-currency-cldr-fraction-digits-ui-types.md @@ -0,0 +1,10 @@ +--- +'@object-ui/types': patch +--- + +fix(types): the form face's currency `scale` refusal names the currency's CLDR fraction digits, the spec's wording + +Clause-②: no +Metadata-protocol: none + +`CURRENCY_FIELD_SCALE_REFUSAL` is held equal to `@objectstack/spec`'s currency `scale` refusal, which now says "the currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the amount displays" instead of naming the ISO 4217 minor unit. Words only: the same entries are refused at the same path. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index e007ec45c54..6b0553a56ed 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -234,9 +234,11 @@ what a record-currency amount refuses. { name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } } ``` -A currency amount's decimal places are its currency's ISO 4217 minor unit -(2 for USD, 0 for JPY, 3 for KWD), not a field setting, and they are declared -nowhere. A `currency` field takes no `scale`, and `currencyConfig` has no +A currency amount's decimal places are its currency's CLDR fraction digits +(2 for USD, 0 for JPY, 3 for KWD; not ISO 4217's minor units, which differ for +sixteen codes such as IQD, 0 in CLDR and 3 in ISO 4217: see +[Currency fields](/docs/data-modeling/currency)), not a field setting, and they +are declared nowhere. A `currency` field takes no `scale`, and `currencyConfig` has no decimal-places key: publish-time validation refuses `scale` on the field and `currencyConfig.precision` (removed in `@objectstack/spec` 17.5.0 — no renderer or runtime ever read it), each with a prescription to delete the key. The @@ -245,7 +247,7 @@ amount declares `precision: 18` — and is never compared with the currency. **In the Console:** a numeric input with the currency's symbol. The read-only display, the grid cell, the input's `step` and the rounding applied when the input loses focus all use the -currency's minor unit, so a JPY amount shows no decimals and a KWD amount shows three. With no +currency's CLDR fraction digits, so a JPY amount shows no decimals and a KWD amount shows three. With no currency resolved at all, an amount shows two decimals. A `record`-mode amount resolves only its own record's code, never the tenant default. A field-level `currency` key is refused: the currency is `currencyConfig`. The input is a native number box; see @@ -593,7 +595,7 @@ the parent's form edits the children as rows of a table: - **Booleans.** A derived `boolean` or `toggle` child field becomes a `select` cell storing `'true'` / `'false'`, labelled in the session language. - **Currency.** A `currency` cell shows the resolved currency's symbol, and decimals of that - currency's minor unit; `prefix` replaces the symbol only. + currency's CLDR fraction digits; `prefix` replaces the symbol only. - **Order.** When the columns are derived and the child object has a field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, the grid stamps each row's index into it whenever a row is added, removed, duplicated or dragged, so the order is saved with the rows. diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 359de83e14d..5127d0a8054 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -157,7 +157,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `currencyConfig.currencyField` | `string` | — | Required under `record` and refused under the other two modes: a `text` field of the same object declaring `valueDomain: 'iso_4217_currency'` (the object refuses a pointer at anything else). A write that leaves a record-mode amount holding a value beside a blank code is refused, `ERR_CURRENCY_CODE_REQUIRED` / 400 | | `currencyConfig.homeAmount` | `object` | — | Optional under `record` and refused under the other two modes: the stored home-currency projection, three pointers into the same object, all required — `field` (a `dynamic`-mode `currency` field, `readonly: true`), `rateDate` (a `date` or `datetime` field, the document date that picks the rate) and `rateField` (a `number` field, `readonly: true`). See [Declaring the home amount](/docs/data-modeling/currency#declaring-the-home-amount) | -**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The amount never carries its currency code: under `dynamic` and `fixed` the code is field configuration, carried by `currencyConfig` above, and under `record` it is the value of the record's own code field, an ordinary `text` column judged by its `valueDomain` ([Currency fields](/docs/data-modeling/currency)). Decimal places are the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. +**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The amount never carries its currency code: under `dynamic` and `fixed` the code is field configuration, carried by `currencyConfig` above, and under `record` it is the value of the record's own code field, an ordinary `text` column judged by its `valueDomain` ([Currency fields](/docs/data-modeling/currency)). Decimal places are the currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD; not ISO 4217's minor units, which differ for sixteen codes such as IQD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. ### `percent` diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 538a1acf052..cf4bfd0cb9f 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -290,7 +290,7 @@ fields: | `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). A `record`-mode `currency` amount's code field is a `text` field declaring `iso_4217_currency` ([Currency fields](/docs/data-modeling/currency)). | | `min` | `number` | `number`, `currency` | Minimum numeric value. | | `max` | `number` | `number`, `currency` | Maximum numeric value. | -| `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency` — delete it there: a currency amount's decimal places are its currency's, so its ISO 4217 minor unit decides the display. | +| `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency` — delete it there: a currency amount's decimal places are its currency's, so its CLDR fraction digits decide the display. | | `precision` | `number` | `number`, `currency` | Total digits (including scale). | | `currencyConfig` | `object` | `currency` | Where the amount's currency comes from: `currencyMode` `dynamic` (the tenant default, the default), `fixed` (with `defaultCurrency`) or `record` (with `currencyField`, the sibling code field, and optionally `homeAmount`, the stored home-currency projection: `field` a `dynamic`-mode `currency` field, `rateDate` a `date` or `datetime` field, `rateField` a `number` field — see [Declaring the home amount](/docs/data-modeling/currency#declaring-the-home-amount)). | | `options` | `array` | `select`, `multiselect` | List of valid values. | diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 18edd138654..11b2dc35b15 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -30,7 +30,7 @@ revenue: # - Store a bare number, never a currency code beside it: it shows in # the tenant default currency (plain if none is set), one fixed on the # field, or each record's code in a declared sibling field, at that - # currency's ISO 4217 decimal places + # currency's CLDR fraction digits # - UI shows currency symbol # - Validate numeric precision # - Format for display ($1,234.56) @@ -340,7 +340,7 @@ been removed from the spec. record's code is judged by its field's `valueDomain: iso_4217_currency`, so only ISO 4217 codes pass there - No decimal-places setting: an amount's decimal places are its currency's - ISO 4217 minor unit. The field-level `precision` is the amount's **total** + CLDR fraction digits (not ISO 4217's minor units). The field-level `precision` is the amount's **total** digit count (a DECIMAL(18,2) amount declares `precision: 18`), refused on write when exceeded, with the amount's written decimals counted in it diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0dd60bd8514..bde2d17983b 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -65,8 +65,8 @@ const result = CurrencyConfigSchema.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | -| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | -| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its CLDR fraction digits) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's CLDR fraction digits decide how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -230,7 +230,7 @@ const result = CurrencyConfigSchema.parse(data); | **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. | | **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. | | **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. | -| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's ISO 4217 minor unit decides. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | +| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's CLDR fraction digits decide. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | | **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | @@ -396,7 +396,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. | | **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. | | **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. | -| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's ISO 4217 minor unit decides. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | +| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's CLDR fraction digits decide. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | | **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 83d7d75b031..02364ae26e3 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -234,8 +234,8 @@ const result = ApiMethod.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | -| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | -| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its CLDR fraction digits) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's CLDR fraction digits decide how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -605,8 +605,8 @@ const result = ApiMethod.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | -| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | -| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its CLDR fraction digits) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's CLDR fraction digits decide how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index cc30369a701..a7d94aacabe 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -68,8 +68,8 @@ Add a new field to an existing object | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | -| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | -| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its CLDR fraction digits) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's CLDR fraction digits decide how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -502,8 +502,8 @@ Add a new field to an existing object | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | -| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | -| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its CLDR fraction digits) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's CLDR fraction digits decide how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6ea2036078c..b147a09df45 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1922,7 +1922,7 @@ Type: `string` | **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. | | **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. | | **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. | -| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's ISO 4217 minor unit decides. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | +| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's CLDR fraction digits decide. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. | | **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 530b696e298..4139a59352e 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -1525,7 +1525,7 @@ function validateOne( // Maintainer ruling 5791803339 (batch #215 item 1, letter B): `scale` is // retired from the `currency` type. `FieldSchema` refuses the key there at // parse, with the remedy ruling 5805782503 (batch #218 item 2, letter 乙) - // words (delete the key; the currency's ISO 4217 minor unit decides its + // words (delete the key; the currency's CLDR fraction digits decide its // display), so no authored currency field declares it any more — and this // branch stops reading `def.scale` for the type, so a declaration that // reaches here anyway (a stored field that predates the refusal, a diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 056d875c6be..afb6eabd863 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -429,7 +429,7 @@ "verifiedAt": "2026-09-27", "evidenceScope": "in-repo", "evidence": "ui/packages/i18n/src/currency.ts:60 reads field.currencyConfig.defaultCurrency ONLY when currencyConfig.currencyMode is 'fixed' — the second step of resolveFieldCurrency's precedence chain; a 'dynamic' config, or one naming no mode, skips it for the tenant default localization.currency; ui/packages/fields/src/widgets/CurrencyField.tsx:55 calls resolveFieldCurrency and applies the resolved code at :91 through formatAmount, which passes it to Intl.NumberFormat via formatDisplayNumber — measured objectui @25c7d584", - "note": "NARROWED 2026-10-28 (#27157, ruling 6464202770 on #27119, Q2 = X): `defaultCurrency` lost its `'CNY'` default and is required under `fixed` (a `fixed` config without a code is refused with the prescription); a `dynamic` config parses with no `defaultCurrency`, and stored rows lose the baked code through the ADR-0087 conversion `currency-config-dynamic-default-currency-removed`. The reader is unchanged and was re-read on this date: ui/packages/i18n/src/currency.ts#resolveFieldCurrency still takes the code only under `fixed`, so a `dynamic` config that no longer carries `CNY` displays exactly as before. Verdict unchanged: LIVE. RE-CITED 2026-09-27 (#20126): since objectui#10461 the reader takes defaultCurrency only under currencyMode 'fixed', and the chain no longer cites the date/datetime-semantics ADR, which never names currency; line pointers moved with it (currency.ts :32 → :60, CurrencyField.tsx :45/:51 → :55/:91). Verdict unchanged: LIVE. RE-CITED 2026-08-10 (#7133/#7142): the old pointer 'packages/fields/src/index.tsx:469' now lands in formatRelativeDays, a date-phrase helper with no currency involvement — the resolver moved to @object-ui/i18n and packages/fields/src/currency.ts:14 only re-exports it, which is why the old line drifted onto unrelated code rather than disappearing. Verdict unchanged: LIVE, and now cited at both the reader that consumes the key and the widget that applies the resolved currency. The removed `currencyConfig.precision` (#19992; packages/spec/src/data/field.zod.ts; moved out of spec source, #23434): the widget's one width is the currency's ISO 4217 minor unit, objectui @b9814b334 packages/fields/src/widgets/CurrencyField.tsx#CurrencyField through objectui @b9814b334 packages/fields/src/widgets/number-format.ts#currencyDisplayWidth over objectui @b9814b334 packages/fields/src/currency.ts#currencyFractionDigits, and no objectui code reads `currencyConfig.precision`; re-read 2026-10-15, first cited sha-less (the source then named `currencyFractionDigits(currency)` as the widget's own line)." + "note": "NARROWED 2026-10-28 (#27157, ruling 6464202770 on #27119, Q2 = X): `defaultCurrency` lost its `'CNY'` default and is required under `fixed` (a `fixed` config without a code is refused with the prescription); a `dynamic` config parses with no `defaultCurrency`, and stored rows lose the baked code through the ADR-0087 conversion `currency-config-dynamic-default-currency-removed`. The reader is unchanged and was re-read on this date: ui/packages/i18n/src/currency.ts#resolveFieldCurrency still takes the code only under `fixed`, so a `dynamic` config that no longer carries `CNY` displays exactly as before. Verdict unchanged: LIVE. RE-CITED 2026-09-27 (#20126): since objectui#10461 the reader takes defaultCurrency only under currencyMode 'fixed', and the chain no longer cites the date/datetime-semantics ADR, which never names currency; line pointers moved with it (currency.ts :32 → :60, CurrencyField.tsx :45/:51 → :55/:91). Verdict unchanged: LIVE. RE-CITED 2026-08-10 (#7133/#7142): the old pointer 'packages/fields/src/index.tsx:469' now lands in formatRelativeDays, a date-phrase helper with no currency involvement — the resolver moved to @object-ui/i18n and packages/fields/src/currency.ts:14 only re-exports it, which is why the old line drifted onto unrelated code rather than disappearing. Verdict unchanged: LIVE, and now cited at both the reader that consumes the key and the widget that applies the resolved currency. The removed `currencyConfig.precision` (#19992; packages/spec/src/data/field.zod.ts; moved out of spec source, #23434): the widget's one width is the currency's CLDR fraction digits, objectui @b9814b334 packages/fields/src/widgets/CurrencyField.tsx#CurrencyField through objectui @b9814b334 packages/fields/src/widgets/number-format.ts#currencyDisplayWidth over objectui @b9814b334 packages/fields/src/currency.ts#currencyFractionDigits, and no objectui code reads `currencyConfig.precision`; re-read 2026-10-15, first cited sha-less (the source then named `currencyFractionDigits(currency)` as the widget's own line)." }, "dimensions": { "status": "live", diff --git a/packages/spec/src/conversions/entries/18/currency-config-precision-removed.ts b/packages/spec/src/conversions/entries/18/currency-config-precision-removed.ts index a31c4fbaad8..acf3dc39f65 100644 --- a/packages/spec/src/conversions/entries/18/currency-config-precision-removed.ts +++ b/packages/spec/src/conversions/entries/18/currency-config-precision-removed.ts @@ -19,7 +19,7 @@ import { type Dict, isDict, stripKeys } from '../shared.js'; * ever read (#19992, ADR-0049 enforce-or-remove; triage direction REMOVE under * ruling 乙 on #19910, 「a currency's decimal places are the currency's, not a * setting」). objectui's `CurrencyField` derives an amount's decimal places from - * the currency's ISO 4217 minor unit and never looked at the key; its only + * the currency's CLDR fraction digits and never looked at the key; its only * reader was its own #7918 ISO 4217 contradiction check, which policed a width * nothing applied. * @@ -50,7 +50,7 @@ const currencyConfigPrecisionRemoved: MetadataConversion = { surface: 'object.fields.*.currencyConfig.precision', summary: "currency field key 'currencyConfig.precision' removed (ADR-0049 — no renderer or " - + 'runtime ever read it: an amount\'s decimal places are its currency\'s ISO 4217 minor unit, ' + + 'runtime ever read it: an amount\'s decimal places are its currency\'s CLDR fraction digits, ' + 'derived from the currency itself. Its ISO 4217 contradiction check and the default `2` ' + 'baked into parse output went with it; the field-level `precision` is a total digit count ' + 'and is untouched)', diff --git a/packages/spec/src/data/currency-precision-iso4217.test.ts b/packages/spec/src/data/currency-precision-iso4217.test.ts index 316092c6bcc..0000aba9b38 100644 --- a/packages/spec/src/data/currency-precision-iso4217.test.ts +++ b/packages/spec/src/data/currency-precision-iso4217.test.ts @@ -8,8 +8,8 @@ * #19992 — `currencyConfig.precision` is REMOVED (ADR-0049 enforce-or-remove). * It was declared, validated against ISO 4217 by the #7918 rule, and baked to * `2` into parse output by the #11423 `.overwrite()` — and read by nothing: - * objectui's `CurrencyField` derives the width from the currency's ISO 4217 - * minor unit. The #7918 / #11423 blocks that used to open this file pinned a + * objectui's `CurrencyField` derives the width from the currency's CLDR + * fraction digits. The #7918 / #11423 blocks that used to open this file pinned a * rule over a key nobody honoured; they are replaced below by the retirement's * own pins (the refusal, its two alias spellings, the parse output, and the * ADR-0087 conversion at rest). @@ -57,7 +57,7 @@ describe('`currencyConfig.precision` is removed: refused with the prescription, expect(issue.message).toContain(RETIREMENT_LEAD); expect(issue.message).toContain('no renderer or runtime ever read it'); expect(issue.message).toContain( - "a currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD)", + "a currency amount's decimal places are its currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD)", ); expect(issue.message).toContain('Do not move the number to the field-level `precision`'); expect(issue.message).toContain('Delete the key.'); @@ -105,7 +105,7 @@ describe('`currencyConfig.precision` is removed: refused with the prescription, expect(issue.message).toContain( `\`currencyConfig.${key}\` is not a currency configuration key, and nothing replaces it`, ); - expect(issue.message).toContain("ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD)"); + expect(issue.message).toContain("CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD)"); expect(issue.message).toContain('Delete the key.'); expect(issue.message).not.toContain('Did you mean'); // Never keys: no conversion strips them, so the message names no command. @@ -224,7 +224,7 @@ describe('field-level `precision` is total digits, never judged against the curr expect(result.data!.precision).toBe(2); }); - it('flipped: `precision: 2` on a fixed-JPY field parses — a DECIMAL(2,0) amount — where the ISO 4217 width check used to refuse it', () => { + it('flipped: `precision: 2` on a fixed-JPY field parses — a DECIMAL(2,0) amount — where the retired #7918 width check used to refuse it', () => { const result = FieldSchema.safeParse({ ...base, precision: 2, currencyConfig: fixed('JPY') }); expect(result.success).toBe(true); expect(result.data!.precision).toBe(2); diff --git a/packages/spec/src/data/field-currency-scale-refused.test.ts b/packages/spec/src/data/field-currency-scale-refused.test.ts index 0f0ed4dd3fd..123b3e15b3f 100644 --- a/packages/spec/src/data/field-currency-scale-refused.test.ts +++ b/packages/spec/src/data/field-currency-scale-refused.test.ts @@ -5,7 +5,7 @@ * 5791803339, batch #215 item 1, letter B): a currency field carrying `scale` * is refused at parse. The remedy is worded by ruling 5805782503 (batch #218 * item 2, letter 乙 — a currency's decimal places are the currency's, not a - * setting): delete the key; the currency's ISO 4217 minor unit decides its + * setting): delete the key; the currency's CLDR fraction digits decide its * display, and its write allowance stays unconstrained. It names no other key * to carry the value, and the remedy pins below hold both halves: the ruled * wording is present, and the retired pointer is absent. @@ -49,7 +49,7 @@ function scaleIssues(result: { success: boolean; error?: { issues: Issue[] } }): */ function expectRuledRemedy(message: string): void { expect(message.startsWith('`scale` is not valid on a `currency` field — delete the key.')).toBe(true); - expect(message).toContain('the currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the amount displays'); + expect(message).toContain('the currency\'s CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the amount displays'); expect(message).toContain('the field\'s write allowance stays unconstrained'); expect(message).not.toMatch(/currencyConfig|precision/); } @@ -138,7 +138,7 @@ describe('CONTROLS: what the refusal must leave alone', () => { const description = (FieldSchema.shape as Record).scale?.description ?? ''; expect(description).toContain('Applies to `number`, `percent`, `rating` and `slider`'); expect(description).toContain('REFUSED on a `currency` field — delete it there'); - expect(description).toContain('the currency\'s ISO 4217 minor unit decides how the amount displays'); + expect(description).toContain('the currency\'s CLDR fraction digits decide how the amount displays'); expect(description).not.toContain('currencyConfig'); }); }); @@ -242,3 +242,27 @@ describe('no registered metadata form OFFERS `scale` on a currency field', () => expect(offered).toEqual([]); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// The digit source the refusal names is the one the platform uses. +// +// `CURRENCY_FRACTION_DIGITS` is a CLDR `currencyData` snapshot and every +// display face asks `Intl` (ICU, the same CLDR data), so an amount's decimal +// places are the currency's CLDR fraction digits. ISO 4217's minor unit is a +// different number for sixteen codes (IQD 0 against 3; AFN, ALL, COP, HUF, +// IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP and YER 0 against 2), so a +// refusal naming ISO 4217 tells an IQD author to expect three decimals the +// platform never shows. +// ──────────────────────────────────────────────────────────────────────────── + +describe('the currency `scale` refusal names CLDR fraction digits, not the ISO 4217 minor unit', () => { + it('the refusal and the `scale` describe say CLDR and no longer say "ISO 4217 minor unit"', () => { + const [issue] = scaleIssues(FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale: 2 })); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toContain('CLDR fraction digits'); + expect(issue?.message).not.toContain('ISO 4217 minor unit'); + const description = (FieldSchema.shape as Record).scale?.description ?? ''; + expect(description).toContain('CLDR fraction digits'); + expect(description).not.toContain('ISO 4217 minor unit'); + }); +}); diff --git a/packages/spec/src/data/field-scale.test.ts b/packages/spec/src/data/field-scale.test.ts index b91f8b1f3c2..1b07b442829 100644 --- a/packages/spec/src/data/field-scale.test.ts +++ b/packages/spec/src/data/field-scale.test.ts @@ -55,7 +55,7 @@ describe('resolveFieldScale', () => { }); it('declares NO width for currency — the key is retired from the type', () => { - // `currency` resolves its cell's fraction digits from ISO 4217 minor units + // `currency` resolves its cell's fraction digits from CLDR's count // and can no longer declare this key at all (retired from the type, // refused at parse). expect(resolveFieldScale({ type: 'currency' })).toBeUndefined(); diff --git a/packages/spec/src/data/field-scale.ts b/packages/spec/src/data/field-scale.ts index 233679e0926..9dc6be93016 100644 --- a/packages/spec/src/data/field-scale.ts +++ b/packages/spec/src/data/field-scale.ts @@ -104,9 +104,9 @@ * the `currency` type (#19629, ruling 5791803339 letter B) and refused at * parse, so a currency field declares no `scale` for this table to default. * Ruling 5805782503 (letter 乙) words the refusal's remedy: a currency's - * decimal places are the currency's, not a setting — its ISO 4217 minor - * unit decides its display. The amount's cell resolves its fraction - * digits from the currency's own ISO 4217 minor-unit count and never read + * decimal places are the currency's, not a setting — its CLDR fraction + * digits decide its display. The amount's cell resolves its fraction + * digits from the currency's own CLDR fraction-digit count and never read * the key; when the ruling landed (the renderer reading is recorded in * `liveness/field.json`, row `scale`), the * grid summary footer and the dashboard metric widget did read it on a diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 70d57b429c8..8dbfe3707f5 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -78,8 +78,8 @@ export const fieldForm = defineForm({ // #19992 — the help text follows the key's describe ("Total digits") // and the object designer's quick-add row (`object.form.ts`): the old // "Decimal places (e.g., 2 for $10.50)" taught the one reading the - // contract refuses. A currency's decimal places are its ISO 4217 minor - // unit and are declared nowhere. + // contract refuses. A currency's decimal places are its CLDR fraction + // digits and are declared nowhere. { field: 'precision', visibleWhen: "data.type == 'currency' || data.type == 'number'", helpText: 'Total digits' }, { field: 'scale', visibleWhen: "data.type == 'number'", helpText: 'Number of decimal digits' }, // `useGrouping` is shown once it is enforced (ruling 5916202706: offer diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 836b2afb7ac..c68730f0dc6 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -489,7 +489,7 @@ export const LocationCoordinatesSchema = lazySchema(() => z.object({ * tombstone for the removed key and the answers for its two natural spellings. */ const CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS = - 'a currency amount\'s decimal places are its currency\'s ISO 4217 minor unit (2 for USD, ' + 'a currency amount\'s decimal places are its currency\'s CLDR fraction digits (2 for USD, ' + '0 for JPY, 3 for KWD), which every display face derives from the currency itself, so ' + 'there is no decimal-places setting to declare. Do not move the number to the ' + 'field-level `precision`: that key is the amount\'s TOTAL digit count (a DECIMAL(18,2) ' @@ -501,8 +501,8 @@ const CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS = * #19992 (ADR-0049 enforce-or-remove, triage direction REMOVE under ruling 乙 on * #19910 — 「a currency's decimal places are the currency's, not a setting」): * `precision` was a declared, validated key that no renderer or runtime ever - * read. objectui's `CurrencyField` derives the width from the currency's ISO - * 4217 minor unit (read points recorded in `liveness/field.json`, row + * read. objectui's `CurrencyField` derives the width from the currency's CLDR + * fraction digits (read points recorded in `liveness/field.json`, row * `currencyConfig`), and no code read the key at all — measured with a lit * control (`currencyConfig.currencyMode` IS read) over objectstack * `packages/**` + `examples/**`, objectui (the console's tree and `main`) and @@ -708,8 +708,8 @@ function refuseSummaryWithoutField( * - Custom business-specific codes * Stricter validation can be implemented at the application layer based on business requirements. * - * There is no decimal-places key: a currency's decimal places are its ISO 4217 - * minor unit (see {@link CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE} for the + * There is no decimal-places key: a currency's decimal places are its CLDR + * fraction digits (see {@link CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE} for the * removed `precision`). * * `defaultCurrency` has no default (#27157, ruling 6464202770 on #27119, Q2 = @@ -1078,13 +1078,13 @@ const INLINE_GRID_COLUMN_HISTORY = * as `FieldSchema`'s currency refusal with the subject swapped, same remedy: * delete the key, nothing replaces it. * `inline-grid-column-currency-scale-refused.test.ts` holds the two refusals - * to the same first sentence and the same minor-unit clause, so a rewording of - * one that leaves the other behind goes red. + * to the same first sentence and the same fraction-digits clause, so a rewording + * of one that leaves the other behind goes red. */ const INLINE_GRID_CURRENCY_SCALE_REFUSAL = '`scale` is not valid on a `currency` inline grid column — delete the key. A currency ' - + 'amount\'s decimal places are its currency\'s, not a column setting: the currency\'s ISO 4217 ' - + 'minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the cell displays the amount and the ' + + 'amount\'s decimal places are its currency\'s, not a column setting: the currency\'s CLDR ' + + 'fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the cell displays the amount and the ' + 'width a computed amount is rounded to. `scale` stays valid on a `number` column.'; /** @@ -1156,7 +1156,7 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ // `type: 'currency'` by the `.superRefine` below (ruling B carried to this // mirror); the describe names the one type set it still applies to. scale: z.number().int().nonnegative().max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() - .describe('Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: \'currency\'` — delete it there: the currency\'s ISO 4217 minor unit decides. The upper bound is the renderer\'s: the grid rounds with `toFixed`, which throws a RangeError above 100.').meta({ title: 'Scale' }), + .describe('Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: \'currency\'` — delete it there: the currency\'s CLDR fraction digits decide. The upper bound is the renderer\'s: the grid rounds with `toFixed`, which throws a RangeError above 100.').meta({ title: 'Scale' }), autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable.").meta({ title: 'Autofill' }), readonlyWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`).").meta({ title: 'Read-only When' }), requiredWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD\'s own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.').meta({ title: 'Required When' }), @@ -1460,7 +1460,7 @@ export const FieldSchema = lazySchema(() => { // stays the fixed exact decimal of NUMERIC_COLUMN_REPRESENTATION. The // describe states the counting rule because the field reference page is // generated from it, and that page is what an author reads. - precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field\'s decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value\'s own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount\'s written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency\'s are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform\'s fixed exact decimal whatever this declares. Not read on any other field type.'), + precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field\'s decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value\'s own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount\'s written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency\'s are its CLDR fraction digits) and not a column size: every numeric column keeps the platform\'s fixed exact decimal whatever this declares. Not read on any other field type.'), // #18972 — and an UPPER bound, for the same declared=enforced reason one // axis over: `scale` is unrenderable above 100 at every consumer, so a // larger declaration could only ever crash a reader. See @@ -1485,7 +1485,7 @@ export const FieldSchema = lazySchema(() => { // (batch #218 item 2, letter 乙) words. The describe names the type set the // key still applies to, for the same reason as above. scale: z.number().int().min(0).max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() - .describe('Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount\'s decimal places are its currency\'s, so the currency\'s ISO 4217 minor unit decides how the amount displays, and a currency write\'s decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field\'s storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform\'s, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`\'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer.'), + .describe('Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount\'s decimal places are its currency\'s, so the currency\'s CLDR fraction digits decide how the amount displays, and a currency write\'s decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field\'s storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform\'s, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`\'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer.'), min: z.number().optional().describe('Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead.'), max: z.number().optional().describe('Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead.'), /** @@ -3136,13 +3136,13 @@ export const FieldSchema = lazySchema(() => { // 「215 同意」): `scale` is RETIRED from the `currency` type and refused at // this seam. On a currency field the key was three-faced: the field // designer offered it, the amount's cell never read it (the cell's fraction - // digits are the currency's own ISO 4217 minor-unit count), and + // digits are the currency's own CLDR fraction-digit count), and // `packages/objectql`'s `max_scale` branch still refused writes carrying // more decimals — so an author who set it bought a narrower write contract // and no visible change. The ruling retires the key rather than aligning the // money faces to it. The remedy is worded by ruling 5805782503 (batch #218 // item 2, letter 乙 — a currency's decimal places are the currency's, not a - // setting): delete the key; the currency's ISO 4217 minor unit decides its + // setting): delete the key; the currency's CLDR fraction digits decide its // display, and its write allowance stays unconstrained (today's contract // for a currency field that declares no `scale`). ⛔ The remedy names no // other key to carry the value: nothing replaces it. ⛔ No alias and no @@ -3159,8 +3159,8 @@ export const FieldSchema = lazySchema(() => { path: ['scale'], message: '`scale` is not valid on a `currency` field — delete the key. A currency amount\'s decimal ' + - 'places are its currency\'s, not a field setting: the currency\'s ISO 4217 minor unit (2 for ' + - 'USD, 0 for JPY, 3 for KWD) decides how the amount displays, and the field\'s write allowance ' + + 'places are its currency\'s, not a field setting: the currency\'s CLDR fraction digits (2 for ' + + 'USD, 0 for JPY, 3 for KWD) decide how the amount displays, and the field\'s write allowance ' + 'stays unconstrained — a currency write is accepted with the decimals it carries, as it always ' + 'was on a currency field that declared no `scale`. The key\'s one enforced effect was refusing ' + 'writes with more decimals, which this field type no longer does.', @@ -3168,7 +3168,7 @@ export const FieldSchema = lazySchema(() => { } // #20011 — the FIELD-level `precision` on a `currency` field is NOT compared - // against the currency's ISO 4217 fraction digits, on purpose. The key is + // against the currency's CLDR fraction digits, on purpose. The key is // declared "Total digits" — the `p` of a DECIMAL(p, s) amount, so // `precision: 18` on a fixed-USD field is DECIMAL(18,2) — and a currency's // decimal places are the currency's, not a setting (ruling 5805782503, diff --git a/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts b/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts index 3470f40fc14..5b5424fdd4d 100644 --- a/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts +++ b/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts @@ -21,7 +21,7 @@ * declares no `type`, parses with its value; `prefix` on a currency * column is still accepted; following the remedy parses. * 3. THE SHAPE PARITY — the field refusal and the column refusal share the - * first sentence (subject swapped) and the minor-unit clause, so a + * first sentence (subject swapped) and the fraction-digits clause, so a * rewording of one that leaves the other behind goes red. * 4. THE DESCRIBES — `prefix` no longer promises a default symbol, and * `scale` names the currency refusal. @@ -60,17 +60,17 @@ const parseColumns = (inlineColumns: Record[]): Result => const FIELD_FIRST_SENTENCE = '`scale` is not valid on a `currency` field — delete the key.'; const COLUMN_FIRST_SENTENCE = '`scale` is not valid on a `currency` inline grid column — delete the key.'; -const MINOR_UNIT_CLAUSE = 'the currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how'; +const FRACTION_DIGITS_CLAUSE = 'the currency\'s CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how'; /** * The ruled remedy on the message itself: the wording is the contract here * (the card carries ruling B's sentence and remedy to the mirror), so the pin - * reads the first sentence verbatim, the minor-unit clause, and the ABSENCE of + * reads the first sentence verbatim, the fraction-digits clause, and the ABSENCE of * any key the author could be sent to instead. */ function expectRuledRemedy(message: string): void { expect(message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true); - expect(message).toContain(MINOR_UNIT_CLAUSE); + expect(message).toContain(FRACTION_DIGITS_CLAUSE); expect(message).not.toMatch(/currencyConfig|precision/); } @@ -170,7 +170,7 @@ describe('CONTROLS: what the refusal must leave alone', () => { }); describe('SHAPE PARITY with the currency FIELD refusal (its ruled text carried, not reworded)', () => { - it('both refusals open with the same sentence, subject swapped, and share the minor-unit clause', () => { + it('both refusals open with the same sentence, subject swapped, and share the fraction-digits clause', () => { const field = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale: 2 }) as Result; const column = parseColumns([{ name: 'amount', type: 'currency', scale: 2 }]); const [fieldIssue] = scaleIssues(field); @@ -179,7 +179,7 @@ describe('SHAPE PARITY with the currency FIELD refusal (its ruled text carried, expect(COLUMN_FIRST_SENTENCE).toBe(FIELD_FIRST_SENTENCE.replace('`currency` field', '`currency` inline grid column')); expect(columnIssue.message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true); for (const message of [fieldIssue.message, columnIssue.message]) { - expect(message).toContain(MINOR_UNIT_CLAUSE); + expect(message).toContain(FRACTION_DIGITS_CLAUSE); expect(message).not.toMatch(/currencyConfig|precision/); } }); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts index 97840a2e4fa..7ee95034e24 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts @@ -6,8 +6,8 @@ // ISO 4217 (#7918) but read by NOTHING — measured with a positive control // (`currencyConfig.currencyMode` IS read) over objectstack, objectui at the // `.objectui-sha` pin and at `main`, and cloud `main`. objectui's -// `CurrencyField` derives decimal places from the currency's ISO 4217 minor -// unit and never read the key; its own contradiction check was its only +// `CurrencyField` derives decimal places from the currency's CLDR fraction +// digits and never read the key; its own contradiction check was its only // reader. The `decimals` / `scale` aliases that pointed authors at it went // with it (they now answer with the same prescription). // diff --git a/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts b/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts index 7ae3e649f4a..b0e47088a60 100644 --- a/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts @@ -16,7 +16,7 @@ export const entry: SemanticMigration = { surface: 'object.fields.*.currencyConfig.precision — the decimal-places key of a currency ' + 'field\'s configuration, and its never-accepted `decimals` / `scale` spellings', replacement: '(removed — nothing replaces it.) A currency amount\'s decimal places are its ' - + 'currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the ' + + 'currency\'s CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD), derived from the ' + 'currency itself and declared nowhere. Delete the key. Do not move the number to the ' + 'field-level `precision`: that key is the amount\'s total digit count, not its decimal ' + 'places, and it is unchanged.', @@ -26,8 +26,8 @@ export const entry: SemanticMigration = { + 'output without anyone authoring it — and the delete is lossless: no renderer or runtime ' + 'ever read the key. Every display face derives the width from the currency. Two judgments ' + 'remain, and neither is a rewrite. First, a width that never applied: the old contradiction ' - + 'check judged an authored value only on a `fixed` field whose code has a known ISO 4217 ' - + 'minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto ' + + 'check judged an authored value only on a `fixed` field whose code has a known CLDR ' + + 'fraction-digit count, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto ' + 'or custom code), an author could declare a width other than the one the field displays — ' + 'and read amounts as if it applied. Whether the displayed width is acceptable for that ' + 'field is the author\'s call. Second, code the chain cannot reach: a plugin, integration or ' diff --git a/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts index 00744931636..42fc3c9660c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts @@ -9,7 +9,7 @@ export const entry: SemanticMigration = { + '`number`, `percent`, `rating`, `slider` and `formula` is untouched', replacement: 'no `scale` on a currency field. DELETE the key — that is the whole migration: a ' + 'currency amount\'s decimal places are its currency\'s, not a field setting. The currency\'s ' - + 'ISO 4217 minor unit decides how the amount displays, and the field\'s write allowance stays ' + + 'CLDR fraction digits decide how the amount displays, and the field\'s write allowance stays ' + 'unconstrained — a currency write is accepted with the decimals it carries, as it always was ' + 'on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare ' + 'its value under any other key.', @@ -18,8 +18,8 @@ export const entry: SemanticMigration = { + 'type, and the ruling of 2026-09-24 (option 乙 — a currency\'s decimal places are the ' + 'currency\'s, not a setting) words the remedy. On a currency field the ' + 'key was three-faced: the metadata-admin field designer offered it as stored metadata, the ' - + 'amount\'s cell never read it (fraction digits come from the currency\'s ISO 4217 minor ' - + 'unit), and the record validator\'s `max_scale` branch still refused writes carrying more ' + + 'amount\'s cell never read it (fraction digits come from the currency\'s CLDR fraction-digit ' + + 'count), and the record validator\'s `max_scale` branch still refused writes carrying more ' + 'decimals — so an author who set it bought a narrower write contract and no visible change. ' + '`FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it ' + 'for the type in the same release, so a stored declaration narrows nothing either. ⛔ No ' @@ -40,7 +40,7 @@ export const entry: SemanticMigration = { 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + 'issue on a `scale` path of a `currency` field. A currency field that carried `scale` no ' + 'longer declares it, and a diff of the field shows that one line deleted and no key added. ' - + 'Its amount\'s cell renders the currency\'s ISO 4217 minor-unit digits, as before, and a ' + + 'Its amount\'s cell renders the currency\'s CLDR fraction digits, as before, and a ' + 'write with more decimals than the old `scale` is accepted where it was refused with ' + '`max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and ' + 'still refuse over-scale writes.', diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts index 39fb94c9ed3..4e9440f1430 100644 --- a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts @@ -11,7 +11,7 @@ export const entry: SemanticMigration = { + '`inline-grid-column-identity-only-currency-scale-refused`', replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' - + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'currency\'s CLDR fraction digits decide how the cell displays the amount and the width a ' + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + 'any other key.', reason: diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts index 34aa79041e6..c7e5501070a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts @@ -19,7 +19,7 @@ export const entry: SemanticMigration = { + '`type: \'number\'`, and a column over a field of any other type, keep `scale`', replacement: 'no `scale` on the column. DELETE the key — that is the whole migration: the column ' + 'renders as a currency column, and a currency amount\'s decimal places are its currency\'s. ' - + 'The currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'The currency\'s CLDR fraction digits decide how the cell displays the amount and the width a ' + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + 'any other key, and do not add `type: \'number\'` to keep it on a currency amount.', reason: 'The refusal of `scale` on a currency inline grid column (entry ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts index 1e6de445c86..c17174aad03 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts @@ -29,8 +29,8 @@ export const entry: SemanticMigration = { + 'object\'s field. Write `childObject` on every entry; write `name` where a column said ' + '`field` (or `fieldName`, `key`) or was a bare field-name string; delete `scale` from a ' + 'column that renders as a currency column, whether it declares `type: \'currency\'` or takes ' - + 'it from a `currency` child field — nothing replaces it, the currency\'s ISO 4217 minor unit ' - + 'decides; delete any key neither shape declares.', + + 'it from a `currency` child field — nothing replaces it, the currency\'s CLDR fraction digits ' + + 'decide; delete any key neither shape declares.', reason: 'The block draws one inline grid per detail entry, hydrating an authored column list ' + 'with the same rule and into the same grid as the other two carriers of the inline grid ' + 'column, but nothing judged its entries: a key the renderer does not read was ignored in ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts index 52251393d2c..9c7a934360b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts @@ -22,7 +22,7 @@ export const entry: SemanticMigration = { replacement: 'what the same component is held to at region level: an inline `type: \'modal\'` ' + 'action\'s `target` names a page the stack declares and a `type: \'flow\'` action\'s `target` a ' + 'flow it declares; an identity-only column over a `currency` field of the child object carries ' - + 'no `scale` — delete the key, nothing replaces it, the currency\'s ISO 4217 minor unit decides. ' + + 'no `scale` — delete the key, nothing replaces it, the currency\'s CLDR fraction digits decide. ' + 'The panel, its items and their keys do not change.', reason: '`defineStack` judges a page\'s components through one traversal, shared by its ' + 'inline-action target check and its inline grid column check. The traversal descended an ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts index ba9736eab4b..cafdca06986 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts @@ -32,7 +32,7 @@ export const entry: SemanticMigration = { + 'child object\'s field. Write `name` where a column said `field` (or `fieldName`, `key`); ' + 'delete `scale` from a column that renders as a currency column, whether it declares ' + '`type: \'currency\'` or takes it from a `currency` child field — nothing replaces it, the ' - + 'currency\'s ISO 4217 minor unit decides; delete `addLabel`, `formFields` ' + + 'currency\'s CLDR fraction digits decide; delete `addLabel`, `formFields` ' + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + 'the child object), and any other key the shape does not declare.', diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 0c44b1c1c71..5600bdd93ca 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1627,7 +1627,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ text: 'It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): ' + 'declared and validated against ISO 4217, read by no renderer or runtime — a currency ' - + 'amount\'s decimal places are its currency\'s ISO 4217 minor unit, derived from the ' + + 'amount\'s decimal places are its currency\'s CLDR fraction digits, derived from the ' + 'currency itself. The D2 conversion `currency-config-precision-removed` strips it from ' + 'every field\'s `currencyConfig` as a pure lossless delete, which matters most at rest: ' + 'the schema used to bake `precision: 2` into parse output, so stored object rows and ' diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.currencyScale-10221.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.currencyScale-10221.test.tsx index 4688b75e2ff..9b2deba4c8b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.currencyScale-10221.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.currencyScale-10221.test.tsx @@ -6,7 +6,7 @@ * * `scale` is retired from the currency type (ruling B on * objectstack-ai/objectstack#19629), and a currency's decimal places are the - * currency's ISO 4217 minor unit, not a setting (ruling 乙 on + * currency's CLDR fraction digits, not a setting (ruling 乙 on * objectstack-ai/objectstack#19910). The numeric section used to render the * same four controls for `number`, `currency` and `percent`, so the designer * offered a decimal-places knob on money that no display face honours. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 0a8524c244a..aa672857f2f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -559,7 +559,7 @@ function isNumeric(type: string): boolean { * Every numeric type but `currency` (objectui#10221). `scale` is retired from * the currency type (ruling B on objectstack-ai/objectstack#19629), and ruling * 乙 on objectstack-ai/objectstack#19910 is that a currency's decimal places are - * the currency's ISO 4217 minor unit, not a setting — so no display face reads + * the currency's CLDR fraction digits, not a setting — so no display face reads * `scale` on a currency and a control for it would only write a key nothing * honours. `precision` stays offered on currency: it is the field-level TOTAL * digit count of the stored decimal, not a decimal-places knob, and this diff --git a/ui/packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx b/ui/packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx index e27653f85c3..841a86e4e29 100644 --- a/ui/packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx +++ b/ui/packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx @@ -13,7 +13,7 @@ * BOTH `Intl` bounds, and the widget defaulted that width to a literal `2`. So * a JPY field rendered `¥1,234.50` for the same reason `formatCurrency` did, * one layer up. That card made the width derive from the currency's own - * ISO 4217 minor-unit count — pinned below, unchanged. + * CLDR fraction-digit count — pinned below, unchanged. * * objectui#10276: that card ALSO let an authored field-level `precision` win * over the currency. `@objectstack/spec` declares that key as "Total digits diff --git a/ui/packages/fields/src/__tests__/PercentCurrencyField.readonlyDisplay-11444.test.tsx b/ui/packages/fields/src/__tests__/PercentCurrencyField.readonlyDisplay-11444.test.tsx index 88d0d1e8f57..fac93b1d314 100644 --- a/ui/packages/fields/src/__tests__/PercentCurrencyField.readonlyDisplay-11444.test.tsx +++ b/ui/packages/fields/src/__tests__/PercentCurrencyField.readonlyDisplay-11444.test.tsx @@ -23,8 +23,8 @@ * declared `max > 1` rule; the cell's `percentDisplayValue`), so every row * below holds a value the two readings scale identically. * - **currency:** the cell's `formatCurrency` dropped the fraction of a whole - * amount (the objectui#4033 trimming) while the read-only face kept the ISO - * 4217 minor-unit width (objectui#10276). Triage ruled the declared width is + * amount (the objectui#4033 trimming) while the read-only face kept the CLDR + * fraction-digit width (objectui#10276). Triage ruled the declared width is * the protocol's convention and retired the trimming (objectui#11444, * comment 5946462862), so both faces now read a whole USD `3456` as * `$3,456.00`, and the face calls `formatCurrency` itself. @@ -127,7 +127,7 @@ describe('read-only percent and currency faces render through the cell formatter }); }); - describe('currency: the declared width, ISO 4217 minor units, on both faces', () => { + describe('currency: the declared width, CLDR fraction digits, on both faces', () => { it('a whole USD 3456 reads `$3,456.00` in the form and in the cell', async () => { const form = await readonlyText('currency', 3456, fixed('USD'), 'en'); expect(form).toBe('$3,456.00'); diff --git a/ui/packages/fields/src/__tests__/formatCurrency.centsDigit.test.tsx b/ui/packages/fields/src/__tests__/formatCurrency.centsDigit.test.tsx index c1c7d02a260..817c1faab1d 100644 --- a/ui/packages/fields/src/__tests__/formatCurrency.centsDigit.test.tsx +++ b/ui/packages/fields/src/__tests__/formatCurrency.centsDigit.test.tsx @@ -26,7 +26,7 @@ * 2. **Whole amounts** — the OTHER half of the wholeness switch. This half * pinned the objectui#4033 whole-amount trimming ("Salesforce convention: * `$1,234.50` keeps cents; `$1,234` does not") until objectui#11444 - * retired it: triage ruled the declared width, the ISO 4217 minor unit, + * retired it: triage ruled the declared width, the CLDR fraction digits, * the protocol's convention (comment 5946462862), so a whole amount now * shows its `.00`, as the read-only form always did. The rows moved on * purpose and stay as a control: a range `[0, 2]` would trim them again. diff --git a/ui/packages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx b/ui/packages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx index 010d6147c28..93050dded18 100644 --- a/ui/packages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx +++ b/ui/packages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx @@ -7,7 +7,7 @@ */ /** - * objectui#4361 — currency formatting overrode each currency's own ISO 4217 + * objectui#4361 — currency formatting overrode each currency's own CLDR * fraction-digit convention. * * `formatCurrency` picked its width from the VALUE's wholeness alone @@ -27,8 +27,8 @@ * 2. **A whole amount** — this half pinned the whole-amount trimming, the * Salesforce convention objectui#4033 pinned and objectui#4332 / PR #4362 * re-pinned and this card extended (`KWD 1`, not `KWD 1.000`), until - * objectui#11444 retired it: triage ruled the declared width, the ISO 4217 - * minor unit, the protocol's convention (comment 5946462862). A whole + * objectui#11444 retired it: triage ruled the declared width, the CLDR + * fraction digits, the protocol's convention (comment 5946462862). A whole * amount now takes the currency's width too (`KWD 1.000`, `$1,234.00`), * as the read-only form always showed it. The rows below moved on purpose * and stay as controls: a range `[0, width]` would trim them again, and @@ -82,7 +82,7 @@ describe('the environment this card is measured in', () => { }); describe('formatCurrency — a fractional amount takes the CURRENCY\'s width (objectui#4361)', () => { - it('the card\'s two fixtures render as ISO 4217 says they should', () => { + it('the card\'s two fixtures render as CLDR says they should', () => { // Before: `¥1,234.50` — cents on a currency that has none. expect(formatCurrency(1234.5, 'JPY', 'en-US')).toBe('¥1,235'); // Before: `KWD 1.50` — one digit short of the three a dinar has. diff --git a/ui/packages/fields/src/currency.ts b/ui/packages/fields/src/currency.ts index 6c6680cb0b3..0f36ff80075 100644 --- a/ui/packages/fields/src/currency.ts +++ b/ui/packages/fields/src/currency.ts @@ -22,7 +22,7 @@ export { resolveFieldCurrency, recordCurrencyField } from '@object-ui/i18n'; const DEFAULT_FRACTION_DIGITS = 2; /** - * ISO 4217 minor-unit width, memoized per currency code. + * CLDR fraction-digit width, memoized per currency code. * * The probe is deliberately made with NO locale. Measured across * en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime @@ -38,7 +38,7 @@ const DEFAULT_FRACTION_DIGITS = 2; const fractionDigitsByCurrency = new Map(); /** - * The number of fraction digits ISO 4217 gives `currency` — 0 for JPY/KRW/CLP, + * The number of fraction digits CLDR gives `currency` — 0 for JPY/KRW/CLP, * 2 for USD/EUR/CNY, 3 for KWD/BHD/OMR/TND — as ICU reports it. * * Returns {@link DEFAULT_FRACTION_DIGITS} when the code is not a currency code diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index d3fac6ee8bc..6e2ee802ceb 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -579,8 +579,8 @@ export { resolveFieldCurrency }; * `./widgets/number-format.ts`, re-exported here under its long-standing * `@object-ui/fields` name: `CurrencyField`'s read-only branch calls the same * function `CurrencyCellRenderer` below calls, and the lazily loaded widget - * cannot import this barrel (objectui#11444). The width (the currency's ISO - * 4217 minor-unit count, every amount, no whole-amount trimming) and its + * cannot import this barrel (objectui#11444). The width (the currency's CLDR + * fraction-digit count, every amount, no whole-amount trimming) and its * history are documented there. */ export { formatCurrency }; @@ -912,8 +912,8 @@ export function CurrencyCellRenderer({ value, field, dependentValues }: CellRend const currency = resolveFieldCurrency(field as any, tenantCurrency, dependentValues); const num = Number(safe); // `CurrencyField`'s read-only branch makes this same call, so a table cell - // and a read-only form show one amount identically: the currency's ISO 4217 - // minor-unit width, a whole amount included (objectui#11444). + // and a read-only form show one amount identically: the currency's CLDR + // fraction-digit width, a whole amount included (objectui#11444). const formatted = !isNaN(num) ? formatCurrency(num, currency, locale) : String(safe); diff --git a/ui/packages/fields/src/widgets/CurrencyField.tsx b/ui/packages/fields/src/widgets/CurrencyField.tsx index d364b03865b..bdfe4283e3d 100644 --- a/ui/packages/fields/src/widgets/CurrencyField.tsx +++ b/ui/packages/fields/src/widgets/CurrencyField.tsx @@ -19,8 +19,8 @@ export function CurrencyField({ value, onChange, field, readonly, error, classNa const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency, dependentValues); // A currency's decimal places are the CURRENCY's, not a field setting — the // maintainer ruling recorded on objectstack-ai/objectstack#19910 (batch #218 - // item 2), executed here by objectui#10276. The width is the ISO 4217 - // minor-unit count of the resolved currency: 2 for USD / CNY, 0 for JPY, + // item 2), executed here by objectui#10276. The width is the CLDR + // fraction-digit count of the resolved currency: 2 for USD / CNY, 0 for JPY, // 3 for KWD. With no currency resolved there is no minor unit to ask about, // so the historical 2 stays. `currencyDisplayWidth` is that derivation, and // `formatCurrency` (the grid cell's formatter, and this widget's read-only diff --git a/ui/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx b/ui/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx index 282d82262a5..8a67c551823 100644 --- a/ui/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx +++ b/ui/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx @@ -8,7 +8,7 @@ /** * objectui#10355 — a computed `currency` cell in the line-item grid is STORED - * at its currency's ISO 4217 minor unit and SHOWN in that currency. + * at its currency's CLDR fraction digits and SHOWN in that currency. * * `computeRow` rounded a computed currency column to `scale ?? 2`: it read the * `scale` that ruling B (objectstack-ai/objectstack#19629) retired from the diff --git a/ui/packages/fields/src/widgets/GridField.currencyScaleNotRead-10783.test.tsx b/ui/packages/fields/src/widgets/GridField.currencyScaleNotRead-10783.test.tsx index 6646ceefdc3..f891fcf8f3d 100644 --- a/ui/packages/fields/src/widgets/GridField.currencyScaleNotRead-10783.test.tsx +++ b/ui/packages/fields/src/widgets/GridField.currencyScaleNotRead-10783.test.tsx @@ -7,8 +7,8 @@ */ /** - * objectui#10783 — a `currency` grid column's width is its currency's ISO 4217 - * minor unit, and a `scale` on the column decides nothing. + * objectui#10783 — a `currency` grid column's width is its currency's CLDR + * fraction digits, and a `scale` on the column decides nothing. * * `currencyWidth` used to read the column's `scale` first, so an authored * `scale` beat the minor unit on both faces: the value a computed amount is diff --git a/ui/packages/fields/src/widgets/GridField.tsx b/ui/packages/fields/src/widgets/GridField.tsx index 7fe0bf46c73..5ff48bfa5ac 100644 --- a/ui/packages/fields/src/widgets/GridField.tsx +++ b/ui/packages/fields/src/widgets/GridField.tsx @@ -124,8 +124,8 @@ import { GRID_FIELD_RETIRED_KEYS, type GridFieldMetadata, type GridFieldRetiredK * parentheses, numeric literals, `record.qty` or bare `qty`), and written * back into the row so it persists and any running total reflects it. * - `scale` — decimal places for a computed `number` result. ⛔ Not read on - * a `currency` column (objectui#10783): the resolved currency's ISO 4217 - * minor unit decides both the stored and the shown width + * a `currency` column (objectui#10783): the resolved currency's CLDR + * fraction digits decide both the stored and the shown width * (`currencyWidth`), and the spec refuses `scale` on a column declaring * `type: 'currency'`. A column that takes `currency` from its child field * at render time is reported by `hydrateColumns` in @@ -324,7 +324,7 @@ function columnCurrency(tenantCurrency: string | undefined): string | undefined * The fraction width of a `currency` column (objectui#10355) — the ONE * decision behind both its stored computed value and its display. * - * 1. The resolved currency's ISO 4217 minor unit (0 for JPY, 2 for USD, 3 for + * 1. The resolved currency's CLDR fraction digits (0 for JPY, 2 for USD, 3 for * KWD), from `currencyFractionDigits`, the helper every currency face * already uses. It replaced the old default of a literal `2`, under which a * yen amount was stored with cents it does not have and a dinar amount lost diff --git a/ui/packages/fields/src/widgets/PercentField.tsx b/ui/packages/fields/src/widgets/PercentField.tsx index c9452dee21e..86cc674c0c0 100644 --- a/ui/packages/fields/src/widgets/PercentField.tsx +++ b/ui/packages/fields/src/widgets/PercentField.tsx @@ -87,7 +87,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam * the opposite convention and its own `scale` alias; the spec warns against * conflating the two at the field-face declaration itself. And ⛔ not * `CurrencyField`'s width either: a currency's decimal places are the - * currency's own ISO 4217 minor-unit count, and that widget reads neither + * currency's own CLDR fraction-digit count, and that widget reads neither * `precision` nor `scale` for them (objectui#10276). * * A declared `scale: 0` is a valid declaration (a percent field that edits diff --git a/ui/packages/fields/src/widgets/number-format.ts b/ui/packages/fields/src/widgets/number-format.ts index 48dfd4d2db5..799804360c5 100644 --- a/ui/packages/fields/src/widgets/number-format.ts +++ b/ui/packages/fields/src/widgets/number-format.ts @@ -102,8 +102,8 @@ export function formatNumberFieldValue(value: number, field: FieldMetadata, loca } /** - * The display width of a currency amount: the resolved currency's own ISO 4217 - * minor-unit count (2 for USD / CNY, 0 for JPY, 3 for KWD), or the historical 2 + * The display width of a currency amount: the resolved currency's own CLDR + * fraction-digit count (2 for USD / CNY, 0 for JPY, 3 for KWD), or the historical 2 * when no currency is resolved, since there is then no minor unit to ask about. * * A currency's decimal places are the CURRENCY's, not a field setting: the diff --git a/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx b/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx index 600d876ad44..075f563990b 100644 --- a/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx +++ b/ui/packages/plugin-dashboard/src/ObjectMetricWidget.tsx @@ -602,7 +602,7 @@ export const ObjectMetricWidget: React.FC = ({ // field carrying a stale `scale: 2` showed yen cents. With a code resolved, // the amount now goes to `formatCurrency` — the list cell's own formatter // (`CurrencyCellRenderer`), the same one the grid footer takes — so tile, - // footer and cell agree by reference: the currency's ISO 4217 minor-unit + // footer and cell agree by reference: the currency's CLDR fraction-digit // count, a whole amount included (objectui#11444 retired the whole-amount // trimming). The finished string is not a number, so `MetricWidget` shows it // as given. diff --git a/ui/packages/plugin-form/src/MasterDetailForm.tsx b/ui/packages/plugin-form/src/MasterDetailForm.tsx index c51deab4e29..0575f39531c 100644 --- a/ui/packages/plugin-form/src/MasterDetailForm.tsx +++ b/ui/packages/plugin-form/src/MasterDetailForm.tsx @@ -487,7 +487,7 @@ const PLAIN_AMOUNT_DIGITS = 2; * With a currency, the amount is `Intl`'s own currency format through * `formatDisplayNumber`, the formatter the line grid's currency cells use. So * the sign sits where the locale puts it (`$1,234.50`, `1.234,50 $` in de-DE) - * and the width is the currency's ISO 4217 minor unit, the default `Intl` + * and the width is the currency's CLDR fraction digits, the default `Intl` * applies to `style: 'currency'`: 2 for USD, 0 for JPY, 3 for KWD. A * currency's decimal places are the currency's, not a setting. * diff --git a/ui/packages/plugin-form/src/deriveMasterDetail.ts b/ui/packages/plugin-form/src/deriveMasterDetail.ts index 33252b0ef99..43a32b12090 100644 --- a/ui/packages/plugin-form/src/deriveMasterDetail.ts +++ b/ui/packages/plugin-form/src/deriveMasterDetail.ts @@ -252,7 +252,7 @@ function deriveColumn(name: string, d: any, booleanLabel: BooleanValueLabel): Gr * is reported, because the grid does not read it. * * A currency amount's decimal places are its currency's: `GridField`'s - * `currencyWidth` takes the resolved currency's ISO 4217 minor unit and never + * `currencyWidth` takes the resolved currency's CLDR fraction digits and never * the column's `scale` (ruling B on objectstack-ai/objectstack#19629, ruling 乙 * on objectstack-ai/objectstack#19910). `@objectstack/spec` 17.5.0 refuses the * key on an `inlineColumns` entry that DECLARES `type: 'currency'`, and its own @@ -298,7 +298,7 @@ function reportCurrencyColumnScale( console.warn( `[object-ui] \`scale\` is not valid on a \`currency\` inline grid column — delete the key. ` + `Inline grid column '${col.name}' ${how}, so its \`scale: ${String(col.scale)}\` is not read: ` + - `the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the cell ` + + `the currency's CLDR fraction digits (2 for USD, 0 for JPY, 3 for KWD) decide how the cell ` + `displays the amount and the width a computed amount is rounded to.`, ); } diff --git a/ui/packages/plugin-grid/src/useColumnSummary.ts b/ui/packages/plugin-grid/src/useColumnSummary.ts index 84552a1a103..6f4613c6229 100644 --- a/ui/packages/plugin-grid/src/useColumnSummary.ts +++ b/ui/packages/plugin-grid/src/useColumnSummary.ts @@ -414,7 +414,7 @@ function formatSummaryLabel( // (`CurrencyCellRenderer`), the same way the percent arm below takes // `formatPercent` — so footer and cell agree by reference, not by a copy // of the rule. It reads neither `scale` nor `precision`: the width is the - // currency's ISO 4217 minor-unit count, a whole amount included + // currency's CLDR fraction-digit count, a whole amount included // (`$1,234.00`; objectui#11444 retired the whole-amount trimming), and with // no code resolved it is a plain number at two decimals, as the cell // renders it. diff --git a/ui/packages/types/src/form.ts b/ui/packages/types/src/form.ts index 9ac996456e5..3601af11021 100644 --- a/ui/packages/types/src/form.ts +++ b/ui/packages/types/src/form.ts @@ -2071,7 +2071,7 @@ export interface FormField { * each resolves an absent `scale` through the spec's `resolveFieldScale`. * * ⛔ Not on a `currency` field. The `currency` widget does not read it: an - * amount's decimal places are its currency's ISO 4217 minor unit. So the + * amount's decimal places are its currency's CLDR fraction digits. So the * zod face refuses `scale` on an entry of `type: 'currency'`, as the * spec's `FieldSchema` refuses it (objectstack-ai/objectstack#19629), * keyed on `type` as the spec keys it. A typed literal cannot refuse it, diff --git a/ui/packages/types/src/zod/form.zod.ts b/ui/packages/types/src/zod/form.zod.ts index aa027a6dd4c..a0f8507f925 100644 --- a/ui/packages/types/src/zod/form.zod.ts +++ b/ui/packages/types/src/zod/form.zod.ts @@ -1088,8 +1088,8 @@ function retiredGridFieldKey(alias: GridFieldRetiredKey) { */ const CURRENCY_FIELD_SCALE_REFUSAL = '`scale` is not valid on a `currency` field — delete the key. A currency amount\'s decimal ' - + 'places are its currency\'s, not a field setting: the currency\'s ISO 4217 minor unit (2 for ' - + 'USD, 0 for JPY, 3 for KWD) decides how the amount displays, and the field\'s write allowance ' + + 'places are its currency\'s, not a field setting: the currency\'s CLDR fraction digits (2 for ' + + 'USD, 0 for JPY, 3 for KWD) decide how the amount displays, and the field\'s write allowance ' + 'stays unconstrained — a currency write is accepted with the decimals it carries, as it always ' + 'was on a currency field that declared no `scale`. The key\'s one enforced effect was refusing ' + 'writes with more decimals, which this field type no longer does.';