Commit 2b24b8b
Fixes #20309
Clause-②: no (narrowing)
A number, currency, percent, rating, slider or progress field now reads
a **string** by the platform's one numeric grammar, `parseNumericString`
from `@objectstack/spec/data` (landed with PR #20414), instead of
`Number()`-finite, and **stores an admitted string as the number it
denotes**. A string the grammar does not read answers `400
VALIDATION_FAILED` / `invalid_number` on every write door, with nothing
written. This is the card's string half. The non-string half (arrays,
booleans, objects) landed as PR #20370 (`db74b169dc`), so this PR
completes the card.
Measured head: **`6bf61e75a`** (this branch after a true merge of
`origin/main` `fc0db22bc`).
## What changes (read from the code at that head)
- **`packages/objectql/src/validation/record-validator.ts`**
- The number arm (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`,
now spelled once as `isJudgedNumberType` and shared with the rewrite
below) judges a string by `parseNumericString`. ⛔ No private grammar:
the spec's case table `NUMERIC_STRING_GRAMMAR_CASES` decides hex,
padded, exponent and every other form, and this PR pre-decides none of
them. `min`, `max`, `scale` and `precision` read the parsed number. The
existing code and message key (`invalid_number`) are reused.
- New `normalizeNumericStringValues`, beside `normalizeBlankTypedValues`
and with its contract (one record or an array of them; pure; the same
reference back when nothing changed, else a shallow copy). An admitted
string on a field the arm judges becomes its number. It touches only the
fields `validateRecord` walks (never a `SKIP_FIELDS` name, a `system` or
a `readonly` field), never `summary` or another computed type, never a
non-string, and never a string the grammar refuses.
- **`packages/objectql/src/engine.ts`**: the rewrite runs right after
`normalizeBlankTypedValues` at its three call sites: `insert()`,
`update()` (by id and by predicate) and `validate()` (the dry run). So
the middleware, the caller snapshots, the hooks, the `readonlyWhen`
locks and the validator all see the number. Nothing else in `engine.ts`.
The blank rule and its `COMPUTED_VALUE_TYPES` exemption are untouched.
- **Tests** (test side only): the three pin files of this card gain the
string half, each driven by the spec's own
`NUMERIC_STRING_GRAMMAR_CASES`.
- **`.changeset/20309-number-arm-numeric-string-grammar.md`**:
`@objectstack/objectql` `minor`, BREAKING banner, `Clause-②: no
(narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, the
disposition PR #20370's changeset took for this arm.
## Measured, base to head (H1, H3, H4)
Instrument: a scratch script, not committed, booting the real
`ObjectQL`, `ObjectStackProtocolImplementation` and `RestServer` from
the built packages, once on `InMemoryDriver` and once on `SqlDriver`
over better-sqlite3 in memory. Types: the six judged types. Doors:
engine `insert`, `insertMany`, `update` by id, `update` by predicate;
REST `POST /data/:object`, `createMany`, batch create, `PATCH
/data/:object/:id`, batch update, `updateMany`, and `/import` (JSON
rows). Each cell records the answer, the physical cell (memory's own
store; on SQLite the column and its `typeof()`) and `engine.findOne`.
Base `851af0c27` (the branch point), head `c67623f22` (the validator and
engine code measured here is what `6bf61e75a` carries, plus the date arm
that arrived from `main`). 20 inputs x 6 types x 11 doors x 2 drivers =
**2640 cells**.
| input | base, memory | base, SQLite | head, both drivers, every door
but `/import` |
|---|---|---|---|
| `'12'`, `'12.5'`, `'-3'`, `'-0'`, `'0.10'`, `'1e3'` | accepted,
**stored the string**, read back a string | accepted, stored a number by
column affinity | accepted, **stored the number**; the SQLite cell is
byte-identical to base |
| `'0x10'` | accepted, stored the string | accepted, stored the **TEXT**
`'0x10'`, read back as `16` | `invalid_number`, nothing written |
| `' 12 '`, `'12\n'` | accepted, stored the string | accepted, stored
`12` | `invalid_number`, nothing written |
| `'+5'`, `'.5'`, `'5.'`, `'007'` | accepted, stored the string |
accepted, stored `5` / `0.5` / `5` / `7` | `invalid_number`, nothing
written |
| `'1,000'`, `'Infinity'`, `'NaN'`, `'1e400'`, `'abc'` |
`invalid_number` | `invalid_number` | unchanged |
| `''` | `null` (blank rule) | `null` | unchanged |
| `12` (a number) | stored `12` | stored `12` (`real`; `integer` on
`rating`) | unchanged |
Of 2640 cells, **1200 moved**, exactly 60 per moved input (6 types x the
10 non-`/import` doors). The `/import` door moved **0** of its 240
cells: its own cell reader turns a numeric cell into a number before the
engine sees it (below). Refused cells answer `400 VALIDATION_FAILED`
with the field code `invalid_number` on POST and PATCH, a
`VALIDATION_FAILED` row on batch / `createMany` / `updateMany`, and
leave an existing cell unchanged on every update door.
**H4, the narrowing.** Read off the spec table rather than listed by
hand, the strings `Number()` read as finite that the grammar refuses are
exactly `' 12 '`, `'12\n'`, `'\t-3'`, `'0x10'`, `'0X1A'`, `'0o17'`,
`'0b101'`, `'+5'`, `'.5'`, `'5.'`, `'007'` (pinned in
`record-validator.number-value.test.ts`). The changeset names them, with
the before and after answer and the fix (send a JS number or its plain
JSON spelling).
**H5.** Bounds, `scale` and `precision` read the parsed number, so a
string answers byte-for-byte as its number does (pinned over 14 cases):
`'12.50'` passes `scale: 1` and is stored as `12.5`; `'12.55'` and
`'1e-7'` are `max_scale`; `'150'` over `max: 100` is `max_value` (on
`progress` too); `'1234.5'` at `precision: 5, scale: 2` is
`max_precision`; a fraction-stored `percent` keeps its `scale + 2`
allowance. There is no integer check on `rating`, before or after:
`'3.5'` on a `rating` passes unless it declares `scale: 0`.
## The server `/import` route and the grammar (H3)
The route's cell reader, `parseNumberCell`
(`packages/rest/src/import-coerce.ts`), coerces a numeric cell to a JS
number before the write, so the engine's grammar never sees a string
from it on a typed field. Over the 41 rows of
`NUMERIC_STRING_GRAMMAR_CASES` the two **agree on 33** (every admitted
row reads to the same number; hex, octal, binary, non-finite,
placeholders, `'5.'`, `'1_000'`, `'1 000'` refused by both) and
**disagree on 8**, each one the import reader accepting what the grammar
refuses: `' 12 '` / `'12\n'` / `'\t-3'` (it trims), `'1,000'` (it strips
commas), `'1.000,5'` (read as `1.0005`), `'+5'`, `'.5'`, `'007'`. That
is the import route's own documented tolerance and is not changed here
(not in this card's surface).
## Tests, all at `6bf61e75a` unless noted
- `pnpm --filter @objectstack/objectql test`: 329 files, **6584
passed**.
- `pnpm --filter @objectstack/rest test`: 218 files, **4160 passed**, 34
skipped.
- `pnpm --filter @objectstack/objectql --filter @objectstack/rest
typecheck`: exit 0, both test layers OK (`tsc --listFiles` over each
`tsconfig.test.json` includes the edited test files).
- Downstream sweep at `b78c66612` (before the merge):
`@objectstack/service-automation` 149 files, 1837 passed;
`@objectstack/metadata-protocol` 189 files passed, 3 skipped, 2750 tests
passed.
- Pin files: `record-validator.number-value.test.ts` 369 tests,
`engine-number-value-door.test.ts` 275, `rest-data-number-value.test.ts`
277.
- ESLint, narrowed and declared: the 5 changed `.ts` files, `eslint
--no-inline-config --format json`: 5 files linted, 0 errors, 0 warnings.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move any
untouched file's verdict. The repo-wide `pnpm lint` is CI's.
**Ablations** (each through `scripts/ablation-replace.mjs`, which proved
the anchor 1 to 0 and the blob change on disk and restored with blob ==
HEAD and an empty `git diff HEAD`; objectql rebuilt and
`ablation-dist-preflight` confirmed the marker in 4 built files before
each run, and absent from all 14 after each restore rebuild, with a
clean tree):
- **A, the arm reads strings by `Number()` again**
(`parseNumericString(value)` replaced). Predicted 201 reds: 68
validator, 67 engine, 66 REST. Measured objectql **135** failed of 644
(68 + 67) and REST **66** failed of 277, all in the named narrowed
strings, the table-parity and named-narrowing tests, and the dry-run
test.
- **B, the rewrite made a no-op.** Predicted 79 objectql reds and **0**
REST reds, because SQLite's column affinity stores the plain numeric
strings as numbers anyway. Measured objectql **79** failed of 644 (60
driver-payload cases, the hook test, 4 rewrite tests, 14 H5 cases) and
REST **0** failed of 277. So on SQLite the physical-cell pin cannot see
the rewrite; the engine pin on the driver payload is what covers memory
(and MongoDB, which stores the payload as given).
- After both restores: the three pin files 644 / 644 and 277 / 277.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `6bf61e75a`: 66 commands, each run with
its exit code recorded before any pipe. **64 exit 0.** 2 are **NOT
MEASURED** with exit 3 (PREREQUISITE NOT MET, both need every package
built; CI runs them): `pnpm check:dual-build-cjs-loads`, `pnpm
check:type-check-debt`. `--ran` reconciliation: 66 derived, 64 run, 2
NOT-MEASURED, 0 UNRUN.
## Acceptance notes
- **Producer census (triage direction 2).** The seat measured it (answer
5863923799 on the card, objectui source): every interactive form widget
(`NumberField`, `CurrencyField`, `PercentField`, `RatingField`,
`SliderField`, grid inline edit) sends a JS number or `null`, and the
kanban quick add a number or a blank that the blank rule turns into
`null`. One shipped path sends numeric **strings** to the record write
door: objectui's CSV import wizard, legacy per-row fallback
(`plugin-grid/src/ImportWizard.tsx`, `legacyImport` via `validateRow`),
used only when the client cannot reach the server `/import` route.
Re-read in this run at the local objectui checkout `b8e09415c9`: it
posts the raw cell after a client check `!isNaN(Number(value))`, which
covers `number` / `currency` / `percent` only (a `rating`, `slider` or
`progress` cell reaches the server unchecked), and its parser
(`importParsers.ts` `parseDelimited`, and the xlsx reader) trims every
cell. So of the refused forms it can send the radix literals and the
non-JSON spellings (`'0x10'`, `'+5'`, `'.5'`, `'5.'`, `'007'`), and
those rows now fail per row with `invalid_number` where they used to
store a string. Prescription (in the changeset): import through the
server `/import` route, the wizard's default path. The in-repo rows
(example seeds and defaults, flow templates, the `/import` route, the
client SDK, driver read-back) send numbers, as PR #20370 recorded.
- **`/import` and the grammar disagree on 8 rows** (above). Not changed
here. One of them is reported to the seat as a finding: a decimal-comma
cell is misread at the `/import` door, measured through the route on
both drivers: `'3,14'` stored `314`, `'1,5'` stored `15`, `'1.000,5'`
stored `1.0005`, `'1,2,3'` stored `123`, each with `ok: 1, errors: 0`.
- **The earlier pending changeset**
`.changeset/20309-number-arm-non-string-refused.md` says a string "is
still judged by `Number()` and stored as sent. Which strings a number
field accepts is a separate change." This PR is that separate change,
and its own changeset says so. The earlier file is left untouched:
editing another PR's pending changeset is refused by
`check:empty-changeset` (the foreign-changeset rule) unless confirmed as
a deliberate correction.
- **The dispatch asked for a "FROM → TO" line** in the changeset. With
that label `check-adr-0087-registration` reads a migration prescription
and refuses `not-required (no-migration-prescription)`, the disposition
PR #20370 used for this arm. The changeset carries the same mapping as a
"before → after" line with the fix, the spelling the sibling value
narrowing `20386-progress-min-max-enforced.md` uses. Nothing authored
moves, so there is no ledger row to register.
- **Memory stores `-0` for `'-0'`**, the grammar's own value; SQLite
stores `0`.
- **Hooks now see the number.** A `before*` hook reading a numeric field
that a caller sent as a string sees a JS number (pinned). A value a hook
itself writes after the door is not rewritten; the arm still judges it
by the same grammar.
- `driver-memory` is measured at every REST door (the table above) and
pinned at the engine door on the driver payload, not with a new REST
test consumer: `check:driver-memory-census` refuses one without a ruling
(the constraint PR #20370 met).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c1b8c8 commit 2b24b8b
6 files changed
Lines changed: 607 additions & 36 deletions
File tree
- .changeset
- packages
- objectql/src
- validation
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
19 | 26 | | |
20 | 27 | | |
21 | 28 | | |
22 | | - | |
| 29 | + | |
23 | 30 | | |
24 | 31 | | |
25 | 32 | | |
| |||
155 | 162 | | |
156 | 163 | | |
157 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
222 | | - | |
| 222 | + | |
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
| |||
11776 | 11776 | | |
11777 | 11777 | | |
11778 | 11778 | | |
11779 | | - | |
11780 | | - | |
| 11779 | + | |
| 11780 | + | |
| 11781 | + | |
| 11782 | + | |
| 11783 | + | |
| 11784 | + | |
11781 | 11785 | | |
11782 | 11786 | | |
11783 | 11787 | | |
| |||
11948 | 11952 | | |
11949 | 11953 | | |
11950 | 11954 | | |
11951 | | - | |
| 11955 | + | |
| 11956 | + | |
| 11957 | + | |
11952 | 11958 | | |
| 11959 | + | |
11953 | 11960 | | |
11954 | 11961 | | |
11955 | 11962 | | |
| |||
12993 | 13000 | | |
12994 | 13001 | | |
12995 | 13002 | | |
12996 | | - | |
| 13003 | + | |
| 13004 | + | |
| 13005 | + | |
12997 | 13006 | | |
| 13007 | + | |
12998 | 13008 | | |
12999 | 13009 | | |
13000 | 13010 | | |
| |||
0 commit comments