|
26 | 26 | * spec's shared `isValueDomainMember` — the WRITTEN value |
27 | 27 | * only (#14168, maintainer ruling 2026-09-02 option A) |
28 | 28 | * - number types an array, boolean or object is `invalid_number`, never |
29 | | - * coerced (#20309); a number, or a string by `Number()`, |
30 | | - * must be finite |
| 29 | + * coerced (#20309); a number must be finite, and a string |
| 30 | + * must be one the spec's numeric grammar reads |
| 31 | + * (`parseNumericString`) — stored as that number |
31 | 32 | * - `min` / `max` (number/currency/percent/rating/slider/progress — `progress` |
32 | 33 | * since #20386; it takes neither `scale` nor `precision`) |
33 | 34 | * - `scale` more decimal places than the field's STORED allowance → |
@@ -81,6 +82,7 @@ import { |
81 | 82 | COMPUTED_VALUE_TYPES, |
82 | 83 | NON_TEXT_STORED_VALUE_TYPES, |
83 | 84 | percentScaleOf, |
| 85 | + parseNumericString, |
84 | 86 | } from '@objectstack/spec/data'; |
85 | 87 | import type { FieldErrorCode } from '@objectstack/spec/api'; |
86 | 88 | import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared'; |
@@ -658,6 +660,96 @@ function normalizeBlankTypedRow(fields: Record<string, FieldDef>, row: unknown): |
658 | 660 | return out ?? row; |
659 | 661 | } |
660 | 662 |
|
| 663 | +/** |
| 664 | + * [#20309] The declared types the record validator's number arm judges: the |
| 665 | + * spec's numeric class minus its server-computed class, both read as constants. |
| 666 | + * One predicate for the arm and for {@link normalizeNumericStringValues}, so |
| 667 | + * what is judged and what is rewritten cannot drift apart. |
| 668 | + */ |
| 669 | +function isJudgedNumberType(type: string): boolean { |
| 670 | + return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type); |
| 671 | +} |
| 672 | + |
| 673 | +/** |
| 674 | + * [#20309] A STRING on a number-typed field that the platform's numeric grammar |
| 675 | + * reads is written as the NUMBER it denotes — so what the record validator's |
| 676 | + * number arm judges is what the driver stores. |
| 677 | + * |
| 678 | + * The grammar is the spec's one, `parseNumericString` (`@objectstack/spec/data`, |
| 679 | + * #20336): a JSON number literal naming a finite double. The filter door |
| 680 | + * narrows a comparand by the same reading. ⛔ No second grammar here: its case |
| 681 | + * table (`NUMERIC_STRING_GRAMMAR_CASES`) decides hex, padded, exponent and |
| 682 | + * every other form, and this function pre-decides none of them. |
| 683 | + * |
| 684 | + * "Number-typed" is exactly what the arm judges ({@link isJudgedNumberType}), |
| 685 | + * on exactly the fields `validateRecord` walks: never a `SKIP_FIELDS` name, a |
| 686 | + * `system` or a `readonly` field. A value nobody judges is not rewritten. |
| 687 | + * |
| 688 | + * ## Why the door has to say it |
| 689 | + * |
| 690 | + * The arm judged `Number(value)` while the write carried `value`, so an |
| 691 | + * accepted string reached the driver as sent: memory stored `'12'` and read it |
| 692 | + * back as the string `'12'`, while SQLite's column affinity stored the plain |
| 693 | + * forms as numbers but kept `'0x10'` as TEXT (read back as 16). One write, two |
| 694 | + * stored shapes. A shipped producer sends numeric strings — objectui's CSV |
| 695 | + * import legacy per-row fallback posts the raw cell — so the census answer on |
| 696 | + * #20309 accepts the grammar's strings and stores their number rather than |
| 697 | + * refusing every string. |
| 698 | + * |
| 699 | + * ## What it does NOT touch |
| 700 | + * |
| 701 | + * ⛔ A string the grammar does not read: it stays as sent, and the number arm |
| 702 | + * refuses it with `invalid_number`. (A blank never reaches here as a string on |
| 703 | + * these types: {@link normalizeBlankTypedValues} made it `null` first.) ⛔ Every |
| 704 | + * non-string value, of any type. ⛔ `summary` and the other computed types, |
| 705 | + * whose value's shape is their producer's (the seat ruling on #20308). |
| 706 | + * |
| 707 | + * ## Where it runs |
| 708 | + * |
| 709 | + * Beside {@link normalizeBlankTypedValues}, at the same three points of |
| 710 | + * `ObjectQL` — `insert()`, `update()` and `validate()` (the dry run) — before |
| 711 | + * anything reads the payload, so the middleware, the caller snapshots, the |
| 712 | + * hooks, the `readonlyWhen` locks and the validator all see the number. Every |
| 713 | + * REST, batch and import door reaches the engine through those methods. ⛔ No |
| 714 | + * driver copy. A value a `before*` hook writes after the door is the hook's |
| 715 | + * own and is not rewritten; the arm still judges it by the same grammar. |
| 716 | + * |
| 717 | + * Same contract as {@link normalizeBlankTypedValues}: one record or an array of |
| 718 | + * them, pure — the same reference comes back when nothing changed, else a |
| 719 | + * shallow copy (per row, and a copied array). |
| 720 | + */ |
| 721 | +export function normalizeNumericStringValues<T>( |
| 722 | + objectSchema: { fields?: Record<string, FieldDef> } | undefined | null, |
| 723 | + data: T, |
| 724 | +): T { |
| 725 | + const fields = objectSchema?.fields; |
| 726 | + if (!fields || !data || typeof data !== 'object') return data; |
| 727 | + if (Array.isArray(data)) { |
| 728 | + let rows: unknown[] | undefined; |
| 729 | + for (let i = 0; i < data.length; i++) { |
| 730 | + const row = normalizeNumericStringRow(fields, data[i]); |
| 731 | + if (row !== data[i]) (rows ??= data.slice())[i] = row; |
| 732 | + } |
| 733 | + return (rows ?? data) as T; |
| 734 | + } |
| 735 | + return normalizeNumericStringRow(fields, data) as T; |
| 736 | +} |
| 737 | + |
| 738 | +function normalizeNumericStringRow(fields: Record<string, FieldDef>, row: unknown): unknown { |
| 739 | + if (!isPlainRecord(row)) return row; |
| 740 | + let out: Record<string, unknown> | undefined; |
| 741 | + for (const [name, value] of Object.entries(row)) { |
| 742 | + if (typeof value !== 'string' || SKIP_FIELDS.has(name)) continue; |
| 743 | + // Own-property: a field name may be `constructor` / `valueOf`. |
| 744 | + const def = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined; |
| 745 | + if (!def || def.system || def.readonly || !isJudgedNumberType(def.type)) continue; |
| 746 | + const n = parseNumericString(value); |
| 747 | + if (n === undefined) continue; |
| 748 | + (out ??= { ...row })[name] = n; |
| 749 | + } |
| 750 | + return out ?? row; |
| 751 | +} |
| 752 | + |
661 | 753 | /** |
662 | 754 | * Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`, |
663 | 755 | * or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a |
@@ -930,17 +1022,23 @@ function validateOne( |
930 | 1022 | // to parse, so the arm refuses it and never silently alters it (the #7501 |
931 | 1023 | // posture). A number is judged as itself and written as itself. |
932 | 1024 | // |
933 | | - // ⛔ A STRING is still judged by `Number()` and written as sent, exactly as |
934 | | - // before this change. Which strings a number field accepts is a separate |
935 | | - // decision: it waits on the producer census and on the platform's one |
936 | | - // numeric grammar, which belongs to `@objectstack/spec` (#20336), never to a |
937 | | - // second copy here. |
938 | | - if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) { |
| 1025 | + // [#20309] A STRING is judged by the platform's one numeric grammar, |
| 1026 | + // `parseNumericString` (`@objectstack/spec/data`, #20336), never by |
| 1027 | + // `Number()` and ⛔ never by a second grammar here. `Number()` also read a |
| 1028 | + // radix literal (`'0x10'`), a whitespace-padded one (`' 12 '`) and the |
| 1029 | + // non-JSON spellings `'+5'` / `'.5'` / `'5.'` / `'007'` as finite, so those |
| 1030 | + // were accepted and are now `invalid_number`; the grammar's case table |
| 1031 | + // decides every form. An admitted string is judged as the number it denotes, |
| 1032 | + // and `normalizeNumericStringValues` has already written that number into |
| 1033 | + // the payload at the door, so the driver stores what was judged. `min`, |
| 1034 | + // `max`, `scale` and `precision` below read that number, as they read a |
| 1035 | + // number. |
| 1036 | + if (isJudgedNumberType(t)) { |
939 | 1037 | if (typeof value !== 'number' && typeof value !== 'string') { |
940 | 1038 | return fail('invalid_number'); |
941 | 1039 | } |
942 | | - const n = typeof value === 'number' ? value : Number(value); |
943 | | - if (!Number.isFinite(n)) { |
| 1040 | + const n = typeof value === 'number' ? value : parseNumericString(value); |
| 1041 | + if (n === undefined || !Number.isFinite(n)) { |
944 | 1042 | return fail('invalid_number'); |
945 | 1043 | } |
946 | 1044 | // `min` / `max` bind on every type through this door, `progress` included. |
|
0 commit comments