fix(objectql)!: a number field reads a string by the spec's numeric grammar and stores its number (#20309) - #20496
Conversation
…rammar and stores its number (#20309) The record validator's number arm judges a string with parseNumericString (@objectstack/spec/data) instead of Number()-finite, and a new write-side rewrite, normalizeNumericStringValues, stores an admitted string as the number it denotes at the three points normalizeBlankTypedValues runs (insert, update, validate). Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…mmar's case table (#20309) Validator, engine door (stub driver payload, hooks, dry run) and REST on SQLite (physical cell and storage class), each driven by NUMERIC_STRING_GRAMMAR_CASES: admitted strings are judged and stored as their number, refused ones answer invalid_number and write nothing. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…ng half (#20309) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…s the sibling value narrowings do Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 54119a5f2c263f4a4e4ef3f34fb385e52ef2b423 && git checkout 54119a5f2c263f4a4e4ef3f34fb385e52ef2b423
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fc0db22bcfdbdb778945317fc4de6dc46aab966d 6bf61e75aaf6786b56a73ae7184423aa9dce1860 && git checkout -B drift-repro fc0db22bcfdbdb778945317fc4de6dc46aab966d && git merge --no-ff 6bf61e75aaf6786b56a73ae7184423aa9dce1860
node scripts/docs-audit/affected-docs.mjs --json fc0db22bcfdbdb778945317fc4de6dc46aab966d |
Contract reviewServed-tier: Inputs read: card #20309 (body and all 11 comments, 5860329599 through 5876625514), PR #20496 (body, 6-file list, the one bot comment), the net diff against the merge base ① Derived judgments
Sentence walk (changeset and PR body). Every code-fact sentence is TRUE against the head: the arm reads Check-runs on ② Semver level
③ Boundary flagsDev deviations, each answered:
Out-of-scope findings, dispositions:
Reviewer notes (not blocking):
Implemented-by: VERDICT: PASS |
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,
parseNumericStringfrom@objectstack/spec/data(landed with PR #20414), instead ofNumber()-finite, and stores an admitted string as the number it denotes. A string the grammar does not read answers400 VALIDATION_FAILED/invalid_numberon 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 oforigin/mainfc0db22bc).What changes (read from the code at that head)
packages/objectql/src/validation/record-validator.tsNUMERIC_VALUE_TYPESminusCOMPUTED_VALUE_TYPES, now spelled once asisJudgedNumberTypeand shared with the rewrite below) judges a string byparseNumericString. ⛔ No private grammar: the spec's case tableNUMERIC_STRING_GRAMMAR_CASESdecides hex, padded, exponent and every other form, and this PR pre-decides none of them.min,max,scaleandprecisionread the parsed number. The existing code and message key (invalid_number) are reused.normalizeNumericStringValues, besidenormalizeBlankTypedValuesand 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 fieldsvalidateRecordwalks (never aSKIP_FIELDSname, asystemor areadonlyfield), neversummaryor another computed type, never a non-string, and never a string the grammar refuses.packages/objectql/src/engine.ts: the rewrite runs right afternormalizeBlankTypedValuesat its three call sites:insert(),update()(by id and by predicate) andvalidate()(the dry run). So the middleware, the caller snapshots, the hooks, thereadonlyWhenlocks and the validator all see the number. Nothing else inengine.ts. The blank rule and itsCOMPUTED_VALUE_TYPESexemption are untouched.NUMERIC_STRING_GRAMMAR_CASES..changeset/20309-number-arm-numeric-string-grammar.md:@objectstack/objectqlminor, BREAKING banner,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription), the disposition PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370's changeset took for this arm.Measured, base to head (H1, H3, H4)
Instrument: a scratch script, not committed, booting the real
ObjectQL,ObjectStackProtocolImplementationandRestServerfrom the built packages, once onInMemoryDriverand once onSqlDriverover better-sqlite3 in memory. Types: the six judged types. Doors: engineinsert,insertMany,updateby id,updateby predicate; RESTPOST /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 itstypeof()) andengine.findOne. Base851af0c27(the branch point), headc67623f22(the validator and engine code measured here is what6bf61e75acarries, plus the date arm that arrived frommain). 20 inputs x 6 types x 11 doors x 2 drivers = 2640 cells./import'12','12.5','-3','-0','0.10','1e3''0x10''0x10', read back as16invalid_number, nothing written' 12 ','12\n'12invalid_number, nothing written'+5','.5','5.','007'5/0.5/5/7invalid_number, nothing written'1,000','Infinity','NaN','1e400','abc'invalid_numberinvalid_number''null(blank rule)null12(a number)1212(real;integeronrating)Of 2640 cells, 1200 moved, exactly 60 per moved input (6 types x the 10 non-
/importdoors). The/importdoor 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 answer400 VALIDATION_FAILEDwith the field codeinvalid_numberon POST and PATCH, aVALIDATION_FAILEDrow 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 inrecord-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,
scaleandprecisionread the parsed number, so a string answers byte-for-byte as its number does (pinned over 14 cases):'12.50'passesscale: 1and is stored as12.5;'12.55'and'1e-7'aremax_scale;'150'overmax: 100ismax_value(onprogresstoo);'1234.5'atprecision: 5, scale: 2ismax_precision; a fraction-storedpercentkeeps itsscale + 2allowance. There is no integer check onrating, before or after:'3.5'on aratingpasses unless it declaresscale: 0.The server
/importroute 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 ofNUMERIC_STRING_GRAMMAR_CASESthe 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 as1.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
6bf61e75aunless notedpnpm --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 --listFilesover eachtsconfig.test.jsonincludes the edited test files).b78c66612(before the merge):@objectstack/service-automation149 files, 1837 passed;@objectstack/metadata-protocol189 files passed, 3 skipped, 2750 tests passed.record-validator.number-value.test.ts369 tests,engine-number-value-door.test.ts275,rest-data-number-value.test.ts277..tsfiles,eslint --no-inline-config --format json: 5 files linted, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis 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 emptygit diff HEAD; objectql rebuilt andablation-dist-preflightconfirmed the marker in 4 built files before each run, and absent from all 14 after each restore rebuild, with a clean tree):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.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat6bf61e75a: 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.--ranreconciliation: 66 derived, 64 run, 2 NOT-MEASURED, 0 UNRUN.Acceptance notes
NumberField,CurrencyField,PercentField,RatingField,SliderField, grid inline edit) sends a JS number ornull, and the kanban quick add a number or a blank that the blank rule turns intonull. 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,legacyImportviavalidateRow), used only when the client cannot reach the server/importroute. Re-read in this run at the local objectui checkoutb8e09415c9: it posts the raw cell after a client check!isNaN(Number(value)), which coversnumber/currency/percentonly (arating,sliderorprogresscell reaches the server unchecked), and its parser (importParsers.tsparseDelimited, 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 withinvalid_numberwhere they used to store a string. Prescription (in the changeset): import through the server/importroute, the wizard's default path. The in-repo rows (example seeds and defaults, flow templates, the/importroute, the client SDK, driver read-back) send numbers, as PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 recorded./importand 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/importdoor, measured through the route on both drivers:'3,14'stored314,'1,5'stored15,'1.000,5'stored1.0005,'1,2,3'stored123, each withok: 1, errors: 0..changeset/20309-number-arm-non-string-refused.mdsays a string "is still judged byNumber()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 bycheck:empty-changeset(the foreign-changeset rule) unless confirmed as a deliberate correction.check-adr-0087-registrationreads a migration prescription and refusesnot-required (no-migration-prescription), the disposition PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 used for this arm. The changeset carries the same mapping as a "before → after" line with the fix, the spelling the sibling value narrowing20386-progress-min-max-enforced.mduses. Nothing authored moves, so there is no ledger row to register.-0for'-0', the grammar's own value; SQLite stores0.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-memoryis 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-censusrefuses one without a ruling (the constraint PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 met).Generated by Claude Code