Skip to content

Commit db74b16

Browse files
fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) (#20370)
Part of #20309 Clause-②: no (narrowing) A number, currency, percent, rating, slider or progress field now refuses an array, a boolean or an object with `400 VALIDATION_FAILED` / `invalid_number`, on every engine and REST write door. At base `[500]` answered 201 and SQLite stored the TEXT `'[500]'`. A number is judged and stored exactly as before. **A string is unchanged**: it is still judged by `Number()` and written as sent. That is the half this PR leaves open, which is why the first line says `Part of`: see "The string half" below. ## What changes (read from the code at the head below) - **`packages/objectql/src/validation/record-validator.ts`, the number arm** (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`, as #20308 left it). One guard before the old finite check: a value whose `typeof` is neither `number` nor `string` is `invalid_number`. The rest of the arm is byte-identical: the finite check, `progress`'s early return, `min`, `max`, `scale` and every message. - No change in `engine.ts`, no driver change, no REST change. Every REST, batch and import door reaches this validator through `insert()` / `update()`. - `summary` is still not judged (ruling 5860986842 on #20308). A blank is still `null` before the arm (#20308's `normalizeBlankTypedValues`). - `.changeset/20309-number-arm-non-string-refused.md`: `@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`. ## Measured, base to head (H1, H2) 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: number, currency, percent, rating, slider, progress. Doors: engine `insert`, engine `update`, REST `POST /data/:object`, REST `PATCH /data/:object/:id`, REST batch create, REST batch update. Each cell records the door's answer, the physical cell (the memory driver's own store; on SQLite the column and its `typeof()`), `engine.findOne` and REST `GET`. Base is `c74de10a94`. Head is this branch at `bf83ded05c` (the code at the head below is the same). 1016 cells: 14 inputs x 6 types x 6 doors x 2 drivers, plus the 4 H6 cells. | input | base, memory | base, SQLite | head, both drivers | |---|---|---|---| | `[500]` | accepted, stores the array, reads `[500]` | 201, stores TEXT `'[500]'`, reads `"[500]"` | `invalid_number` (REST 400; batch row `VALIDATION_FAILED`), nothing written | | `[]` | accepted, stores `[]` | 201, stores TEXT `'[]'` | `invalid_number`, nothing written | | `true` / `false` | accepted, stores the boolean | 201, stores `1` / `0` (`real`; `integer` on rating) | `invalid_number`, nothing written | | `[5, 7]`, `{}`, `'Infinity'` | `invalid_number` | `invalid_number` | unchanged | | `500`, `12.5` | stored as the number | stored `real` (`integer` for 500 on rating) | unchanged | | `'0x10'` | stored the string | stored TEXT `'0x10'`, read back as `16` | unchanged (the string half) | | `' 12 '`, `'12'`, `'1e3'` | stored the string | stored as a number (column affinity) | unchanged (the string half) | | `'12.5'` | stored the string | stored `real` 12.5 | unchanged (the string half) | Of 1016 cells, exactly 288 moved: `[500]`, `[]`, `true` and `false`, 72 cells each (6 types x 6 doors x 2 drivers). The other 728 are byte-identical base to head in all five columns. **H2.** At base the arm judged `Number(value)` and the driver received `value`: the table's `[500]` row is that gap, read from the raw column. At head a non-string never reaches the driver. A number arrives as the same number: `engine-number-value-door.test.ts` asserts `Object.is` on the driver-facing payload for insert and update. A string still has the gap, and that is the open half. ## The string half, and why this PR does not close the card The seat's update after dispatch set two branches. (a) If no shipped producer sends a numeric string to a number-typed field, refuse every string. (b) If one does, refuse only the non-strings, leave strings exactly as base, open the PR as `Part of`, and sequence the string half after #20336's grammar. The in-repo census (below) finds no producer that sends a numeric string. **objectui's form widgets, the main row, are NOT MEASURED**: this session has no read access to `objectstack-ai/objectui` (REST `GET` answered 403, "GitHub access to this repository is not enabled for this session"), and the request to attach it was refused by the session's permission classifier. So branch (a) cannot be established. This PR takes branch (b): it is the one that refuses no form a shipped producer might send. The string half is an open question in the report, not a guess here. ⛔ No numeric-string grammar is authored in `packages/objectql`. ## Producer census (H3) Every row is a shipped producer of values for a number-typed field, and what it sends. | producer | file | what it sends | measured how | |---|---|---|---| | Example seed records | `examples/app-crm/src/data/index.ts`, `examples/app-showcase/src/data/**` | JS numbers only: 35 values (crm) and 124 (showcase) on numeric fields; app-todo and app-multi-package seed none | each example's `objectstack.config.ts` imported with tsx and every seed record walked against its object's field types | | Numeric `defaultValue`s | the example objects | JS numbers only: 4 (crm), 11 (showcase), 2 (todo) | same walk | | Example flow `create_record` / `update_record` nodes | `examples/app-todo/src/flows/task.flow.ts`, `create_next_task` | one value on a numeric field: `recurrence_interval: '{completedTask.recurrence_interval}'`, a single-token template. `interpolateString` (`packages/services/service-automation/src/builtin/template.ts`) returns the resolved raw value for a single token, so this sends the stored number | same walk; the template rule read at source | | Flow templates in general | `template.ts` | a single token keeps its type; an EMBEDDED template (text around a token) is stringified. No shipped flow puts one on a numeric field | read at source | | CSV / JSON import | `packages/rest/src/import-coerce.ts` (`parseNumberCell`), called by `import-runner.ts` before the engine | a JS number, or the row's own `invalid_number` refusal | read at source | | REST batch, `createMany`, `updateMany`, import doors | `packages/rest` | pass the caller's JSON through to the engine; a door, not a producer | measured above | | `@objectstack/client` | `packages/client/src/index.ts` | serialises the caller's record as JSON; no value stringification | read at source | | Read-modify-write through driver-sql | `packages/drivers/driver-sql/src/sql-driver.ts` (`numericValueFields`) | numeric columns are presented as JS numbers on every dialect, so a record read back and written again carries numbers | read at source | | objectui form widgets | `objectstack-ai/objectui` | **NOT MEASURED** (no read access in this session). Indirect only: PR #20340 measured that a cleared number box sends `null`, not `''` | none | Runtime sweep at head: `@objectstack/service-automation` (147 files, 1767 tests), `@objectstack/rest` and `@objectstack/objectql` all pass with the refusal in place. ## No collateral (H4) - A blank (`''`, `' '`) is still `null` before the arm: #20308's pins pass unchanged (`record-validator.blank-typed-value.test.ts`, `engine-blank-typed-value-door.test.ts`, `rest-data-blank-typed-value.test.ts`), and the new files re-assert it. - `summary` is not judged: the new validator pin writes `[500]`, `true` and a Date to `summary` and all are accepted, as at base. - Existing refusals keep their words and code: `[5, 7]`, `{}`, `NaN`, `Infinity`, `'Infinity'`, `'abc'`, `min_value` / `max_value` on all six types and `max_scale`. That is 192 validator answers (code, message, fields) compared between the base file and the head file, en and zh-CN, insert and update: 192 identical. - A valid JS number is stored byte-identical: the 1016-cell table, and the `Object.is` pin. ## Declaration (H5) `Clause-②: no (narrowing)`, the claim's line. `[500]`, `[]`, `true` and `false` answered 201 at base on memory and SQLite and are refused at head. The census names no shipped producer that sends an array, a boolean or an object to a number-typed field, so no producer's form is refused. The objectui row is NOT MEASURED, as above. - `check-changeset-no-major --base origin/main --event` (this body as the `pull_request` payload): exit 0. The gate printed `LEVEL AXIS: this PR declares clause-② no (narrowing)` and, for the direction arm, `narrowing — a BREAKING change; during the launch window it ships minor`. - `check-adr-0087-registration --base origin/main`: exit 0, "1 declared-breaking changeset(s)", signals `BREAKING+bang+clause-②-narrowing`, disposition `not-required (no-migration-prescription)`. - `check-empty-changeset --base origin/main`: exit 0. #20308's pending changeset (`.changeset/20308-blank-typed-value-null.md`) reads true at this head, so there is no DELIBERATE CORRECTION. ## H6, for the seat (nothing changed for it) Base `c74de10a94`, REST `POST /data/:object`, one field each, bounds declared on the field. The four cells are the same on SQLite and memory, and the same at head. | field | value | answer | stored | |---|---|---|---| | `progress`, `max: 100` | 150 | 201 | 150 (SQLite `real`) | | `progress`, `min: 0` | -5 | 201 | -5 (SQLite `real`) | | `number`, `max: 100` (control) | 150 | 400 `VALIDATION_FAILED` / `max_value` | no row | | `number`, `min: 0` (control) | -5 | 400 `VALIDATION_FAILED` / `min_value` | no row | It reproduces: `progress` stores a value outside the `min` / `max` it declares. ## Tests New files: - `packages/objectql/src/validation/record-validator.number-value.test.ts`: the refusal set on the six judged types, insert and update, as the envelope (`VALIDATION_FAILED` plus `invalid_number`); parity with the spec's `valueSchemaFor` over every non-string input; a characterization that the string half is unchanged, which turns red when it lands; and the blank and `summary` controls. - `packages/objectql/src/engine-number-value-door.test.ts`: what the driver receives on insert, `insert([...])`, `insertMany`, update by id and update by predicate, plus the dry run; a refused value never reaches the driver. - `packages/rest/src/rest-data-number-value.test.ts`, on SQLite: `POST`, batch create, `PATCH`, batch update and `updateMany`, with the physical cell and its `typeof()`. Suites run at `c135a38d31` (this branch after merging `origin/main` `26daf0b036`): - `@objectstack/objectql`, whole suite: 325 files, 6013 tests passed. - `@objectstack/rest`, whole suite: 211 files, 3856 passed and 2 skipped. - `@objectstack/driver-memory`: 57 files, 1374 passed. - `@objectstack/driver-sql` (no live PostgreSQL or MySQL; those files skip): 192 files passed and 11 skipped; 3157 tests passed and 176 skipped. - `@objectstack/service-automation`: 147 files, 1767 passed. - `pnpm --filter @objectstack/objectql --filter @objectstack/rest run typecheck`: exit 0, both test layers included. ## Reverse verification The guard was removed with `scripts/ablation-replace.mjs` (anchor hit once, blob changed), `@objectstack/objectql` was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. Predicted before the run: 68 objectql reds (42 per-input validator cases, the spec-parity case, 24 per-input door cases and the dry run) and 24 REST reds. Measured: objectql 68 failed of 151, REST 24 failed of 43, and every red was one of the predicted cases. Measured at `bf83ded05c`; the merge of `origin/main` that followed changed no file in `packages/objectql` or `packages/drivers`. The restore proved blob equals HEAD and `git diff HEAD` is empty; after a rebuild the `--absent` preflight passed with a clean tree, and the pins were green again (151/151 and 43/43). ## Gates Derived at `c135a38d31` with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 commands, the same 64 as the dispatch list. Each was run at that head with its exit code recorded before any pipe. - `--ran` reconciliation: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED`. - 62 exit 0, including `check:driver-memory-census`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:issue-citations` and `check:type-check-coverage`. - **NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`** (exit 3, `PREREQUISITE NOT MET`). Both need every package built, and CI runs both. - `node scripts/check-issue-citations.mjs --base 26daf0b` (the merge base): exit 0, 4 citations resolve. ## Acceptance notes - **Memory is measured, not pinned at the REST door.** The triage asked for REST pins on driver-sql and driver-memory. A new `@objectstack/driver-memory` test consumer is refused by `check:driver-memory-census` without a ruling, which is the same constraint PR #20340 met. The engine pin reads the driver-facing payload, which memory stores verbatim, and the memory REST cells are in the table above. - **The import route's header** (`packages/rest/src/import-coerce.ts`) says the validator "coerces only to *check* a value and then discards the coerced form". That is still true for strings, and for the boolean and date arms. It is outside this claim's file surface, so it is not edited. - **Stored rows.** A value written before this change is never re-read by the check. The changeset carries a read-only SQLite query that finds TEXT cells in a numeric column; nothing is rewritten. - #20336 and #20351 are not addressed here. The census found a shipped producer of numeric strings: objectui's `plugin-grid` CSV import legacy fallback writes the raw cell (seat answer 5863923799 on #20309). So the string half accepts #20336's grammar and stores the parsed number, and it waits for that grammar. - **Not filed:** the boolean arm has the same judged-vs-written shape (it accepts `0`, `1`, `'0'`, `'1'`, `'true'`, `'false'` and writes the value as sent). It was read at source only, not measured at a door, so it is not a card (filing gate ①). --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70ce802 commit db74b16

5 files changed

Lines changed: 538 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with `invalid_number` (#20309)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING**: shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level). The
12+
narrowing: an array, a boolean or an object whose `Number()` is finite, such as
13+
`[500]`, `[]`, `true` or `false`, written to one of those fields is now refused
14+
with `400 VALIDATION_FAILED` / `invalid_number`. It used to be accepted and
15+
stored as sent.
16+
17+
## What was wrong
18+
19+
The record validator judged `Number(value)` on a number-typed field, but the
20+
write carried `value` itself. Every value that JavaScript coerces to a finite
21+
number therefore passed the check and reached the driver unchanged:
22+
23+
- **SQLite** stored `[500]` as the TEXT `'[500]'`, which a read returned as the
24+
string `"[500]"`; `[]` as the TEXT `'[]'`; and `true` / `false` as `1` / `0`.
25+
- **memory** stored the array or the boolean itself.
26+
27+
`[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`.
28+
29+
## What changes
30+
31+
- On `number`, `currency`, `percent`, `rating`, `slider` and `progress`, a value
32+
that is neither a number nor a string is refused with `invalid_number`: an
33+
array, a boolean, a plain object, a `Date`. This holds on every engine, REST,
34+
batch and import write door, because they all write through the same
35+
validator.
36+
- A number is judged and stored exactly as before, and so are the `min`, `max`
37+
and `scale` checks and their messages.
38+
- A string is also unchanged. It is still judged by `Number()` and stored as
39+
sent. Which strings a number field accepts is a separate change.
40+
- `summary` is still not judged by this check (it is in the spec's
41+
`COMPUTED_VALUE_TYPES`). A blank still becomes `null` before the check runs.
42+
43+
## Rows already stored
44+
45+
This refuses new writes only; a stored value is never re-read by the check.
46+
Rows written earlier on SQLite may hold such a value as TEXT in a numeric
47+
column. To find them, run this once per number-typed column:
48+
49+
```sql
50+
SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text';
51+
```
52+
53+
OBJECT is the object name and FIELD is the field name. A match is a cell that
54+
SQLite could not store as a number: an array written as TEXT, or a string such
55+
as `'0x10'`. Decide its number by hand; nothing here rewrites it.
56+
57+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched and no metadata key is added, removed or reshaped, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What is refused is a caller-written VALUE (an array, a boolean or an object on a number-typed field) at the write door; stored rows are never re-read by the check, and a caller that sends a number, a string or a blank is unaffected. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20309 — on every engine write door, an array, boolean or object on a number
5+
* field is refused before the driver sees it.
6+
*
7+
* The arm used to judge `Number(value)` while the write carried `value`, so
8+
* `[500]`, `[]` and `true` passed and reached the driver as sent. Measured on
9+
* `origin/main` c74de10a94: memory stored each verbatim (the array, the
10+
* boolean), and SQLite stored `'[500]'` / `'[]'` as TEXT and `true` as `1`.
11+
*
12+
* This file pins the DRIVER-FACING half on each door: a refused value never
13+
* reaches the driver (no `create`, `bulkCreate`, `update` or `updateMany`
14+
* call carries it), and an accepted number arrives as the same number
15+
* (`Object.is`), so for a number what the arm judged is what the driver
16+
* receives. Memory and MongoDB store exactly this payload. The SQL physical
17+
* column is pinned in `packages/rest/src/rest-data-number-value.test.ts`.
18+
* A string is not judged differently here: that half waits on #20336.
19+
*/
20+
21+
import { describe, it, expect, beforeEach } from 'vitest';
22+
import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
23+
import { ObjectQL } from './engine.js';
24+
import { ValidationError } from './validation/record-validator.js';
25+
26+
const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t));
27+
const f = (t: string) => `f_${t}`;
28+
29+
/** The card's table and the non-string coercions it named: all refused. */
30+
const REFUSED: ReadonlyArray<readonly [string, unknown]> = [
31+
['[500]', [500]],
32+
['[5, 7]', [5, 7]],
33+
['[]', []],
34+
['true', true],
35+
['false', false],
36+
['{}', {}],
37+
];
38+
39+
interface Call { fn: string; rows: Record<string, unknown>[] }
40+
41+
function makeStubDriver() {
42+
const calls: Call[] = [];
43+
const rows = new Map<string, Record<string, unknown>>();
44+
let n = 0;
45+
const put = (data: Record<string, unknown>) => {
46+
const row = { ...data, id: (data.id as string) ?? `r${++n}` };
47+
rows.set(row.id as string, row);
48+
return row;
49+
};
50+
const driver: any = {
51+
name: 'stub', version: '0.0.0', supports: {},
52+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
53+
async find() { return [...rows.values()]; },
54+
async findOne(_o: string, q: any) {
55+
const id = (q?.where ?? q?.filter ?? q)?.id;
56+
return (typeof id === 'string' ? rows.get(id) : rows.values().next().value) ?? null;
57+
},
58+
async count() { return rows.size; },
59+
async create(_o: string, data: Record<string, unknown>) {
60+
calls.push({ fn: 'create', rows: [{ ...data }] });
61+
return put(data);
62+
},
63+
async bulkCreate(_o: string, list: Record<string, unknown>[]) {
64+
calls.push({ fn: 'bulkCreate', rows: list.map((r) => ({ ...r })) });
65+
return list.map(put);
66+
},
67+
async update(_o: string, id: string, data: Record<string, unknown>) {
68+
calls.push({ fn: 'update', rows: [{ ...data }] });
69+
return put({ ...(rows.get(id) ?? {}), ...data, id });
70+
},
71+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) {
72+
calls.push({ fn: 'updateMany', rows: [{ ...data }] });
73+
return rows.size;
74+
},
75+
async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); },
76+
async delete() { return true; },
77+
async bulkUpdate() { return []; }, async bulkDelete() {},
78+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
79+
async commit() {}, async rollback() {},
80+
};
81+
return { driver, calls };
82+
}
83+
84+
const OBJ = {
85+
name: 'num_door',
86+
label: 'Number door',
87+
fields: {
88+
id: { name: 'id', type: 'text' as const, primaryKey: true },
89+
...Object.fromEntries(JUDGED.map((t) => [f(t), { name: f(t), type: t }])),
90+
},
91+
};
92+
93+
async function refusal(fn: () => Promise<unknown>) {
94+
try {
95+
await fn();
96+
} catch (e) {
97+
expect(e).toBeInstanceOf(ValidationError);
98+
return { code: (e as ValidationError).code, fields: (e as ValidationError).fields.map((x) => [x.field, x.code]) };
99+
}
100+
return null;
101+
}
102+
103+
describe('engine write doors: the number arm judges what the driver receives (#20309)', () => {
104+
let engine: ObjectQL;
105+
let stub: ReturnType<typeof makeStubDriver>;
106+
107+
beforeEach(async () => {
108+
stub = makeStubDriver();
109+
engine = new ObjectQL();
110+
engine.registerDriver(stub.driver, true);
111+
await engine.init();
112+
engine.registry.registerObject(OBJ as any);
113+
});
114+
115+
/** Every value any driver write call carried for `field`. */
116+
const written = (field: string) =>
117+
stub.calls.flatMap((c) => c.rows).filter((r) => field in r).map((r) => r[field]);
118+
119+
describe.each(JUDGED)('%s', (type) => {
120+
it.each(REFUSED)('%s is refused on insert, insert([...]), update by id and update by predicate, and never reaches the driver', async (_l, value) => {
121+
await engine.insert('num_door', { id: 'seed', [f(type)]: 1 });
122+
stub.calls.length = 0;
123+
const expected = { code: 'VALIDATION_FAILED', fields: [[f(type), 'invalid_number']] };
124+
125+
expect(await refusal(() => engine.insert('num_door', { id: 'a', [f(type)]: structuredClone(value) }))).toEqual(expected);
126+
expect(await refusal(() => engine.insert('num_door', [{ id: 'b', [f(type)]: structuredClone(value) }]))).toEqual(expected);
127+
expect(await refusal(() => engine.update('num_door', { id: 'seed', [f(type)]: structuredClone(value) }))).toEqual(expected);
128+
expect(await refusal(() => engine.update('num_door', { [f(type)]: structuredClone(value) }, { where: { id: { $in: ['seed'] } }, multi: true } as any))).toEqual(expected);
129+
130+
const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: structuredClone(value) }]);
131+
expect(outcomes.map((o) => o.ok)).toEqual([false]);
132+
133+
expect(written(f(type))).toEqual([]);
134+
});
135+
});
136+
137+
it('the dry run agrees with the write', async () => {
138+
const refused = await engine.validate('num_door', { id: 'p1', f_number: [500] });
139+
expect(refused.valid).toBe(false);
140+
expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['f_number', 'invalid_number']]);
141+
expect((await engine.validate('num_door', { id: 'p2', f_number: 500 })).valid).toBe(true);
142+
});
143+
144+
it('CONTROL: a finite number reaches the driver as the same number, on insert and update', async () => {
145+
const VALID = [500, 12.5, 0, -3];
146+
for (const type of JUDGED) {
147+
for (const v of VALID) {
148+
stub.calls.length = 0;
149+
await engine.insert('num_door', { id: `v_${type}_${v}`, [f(type)]: v });
150+
await engine.update('num_door', { id: `v_${type}_${v}`, [f(type)]: v });
151+
const got = written(f(type));
152+
expect(got, `${type} ${v}`).toHaveLength(2);
153+
for (const g of got) expect(Object.is(g, v), `${type} ${v}`).toBe(true);
154+
}
155+
}
156+
});
157+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20309 — the number arm refuses a value that is neither a number nor a
5+
* string: an array, a boolean, an object.
6+
*
7+
* ## The defect
8+
*
9+
* The arm judged `Number(value)` and the write carried `value`. So every value
10+
* JS coerces to a finite number passed the check and reached the driver as it
11+
* was sent. Measured on `origin/main` c74de10a94 through the real engine and
12+
* REST doors:
13+
*
14+
* - `[500]`: SQLite stored the TEXT `'[500]'` and memory stored the array;
15+
* - `[]`: SQLite stored `'[]'` and memory stored the array;
16+
* - `true` / `false`: SQLite stored `1` / `0` and memory stored the boolean.
17+
*
18+
* `[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`.
19+
*
20+
* ## What this file pins
21+
*
22+
* - The refusal set on every judged type, on insert and update, as the
23+
* `VALIDATION_FAILED` envelope with `invalid_number`.
24+
* - Parity with the spec over every non-string input: the arm refuses exactly
25+
* what the spec's stored value schema for the type (`valueSchemaFor`,
26+
* `z.number().finite()`) refuses.
27+
* - The controls: a finite number passes, including `0` and a negative; a
28+
* blank is still `null` before the arm (#20308); `summary` is still not
29+
* judged (the seat ruling on #20308).
30+
* - The STRING half is unchanged: a string is still judged by `Number()`, as
31+
* at base. Which strings a number field accepts waits on the producer census
32+
* and the spec's numeric grammar (#20336). The characterization below turns
33+
* red when that half lands, on purpose.
34+
*
35+
* The driver-facing half (what reaches the driver on each engine door) is
36+
* `../engine-number-value-door.test.ts`. The physical column, through REST on
37+
* SQLite, is `packages/rest/src/rest-data-number-value.test.ts`.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES, valueSchemaFor } from '@objectstack/spec/data';
42+
import { normalizeBlankTypedValues, validateRecord, ValidationError } from './record-validator.js';
43+
44+
const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t));
45+
46+
/** Non-strings JS coerces to a finite number, which the old arm let through. */
47+
const NEWLY_REFUSED: ReadonlyArray<readonly [string, unknown]> = [
48+
['[500]', [500]],
49+
['[]', []],
50+
['[\'12\']', ['12']],
51+
['true', true],
52+
['false', false],
53+
['a Date', new Date(0)],
54+
['a Number object', Object(7)],
55+
];
56+
/** Values the old arm already refused; their answer is unchanged. */
57+
const ALREADY_REFUSED: ReadonlyArray<readonly [string, unknown]> = [
58+
['[5, 7]', [5, 7]],
59+
['{}', {}],
60+
['NaN', Number.NaN],
61+
['Infinity', Number.POSITIVE_INFINITY],
62+
["'Infinity'", 'Infinity'],
63+
["'abc'", 'abc'],
64+
];
65+
const ACCEPTED: ReadonlyArray<readonly [string, unknown]> = [
66+
['500', 500],
67+
['12.5', 12.5],
68+
['0', 0],
69+
['-3', -3],
70+
['1e21', 1e21],
71+
];
72+
73+
function schemaOf(types: readonly string[]) {
74+
return { fields: Object.fromEntries(types.map((t) => [`f_${t}`, { name: `f_${t}`, type: t }])) } as any;
75+
}
76+
77+
/** The field-level answer, or `null` when the write is accepted. */
78+
function answer(type: string, value: unknown, mode: 'insert' | 'update') {
79+
try {
80+
validateRecord(schemaOf([type]), { [`f_${type}`]: value }, mode);
81+
return null;
82+
} catch (e) {
83+
expect(e).toBeInstanceOf(ValidationError);
84+
expect((e as ValidationError).code).toBe('VALIDATION_FAILED');
85+
return (e as ValidationError).fields.map((x) => [x.field, x.code]);
86+
}
87+
}
88+
89+
describe('the number arm: an array, boolean or object is invalid_number (#20309)', () => {
90+
it('the judged population is the spec numeric class minus the computed class: the six types', () => {
91+
// A control on the set itself: if it emptied, every case below would pass
92+
// over nothing.
93+
expect([...JUDGED].sort()).toEqual(['currency', 'number', 'percent', 'progress', 'rating', 'slider']);
94+
});
95+
96+
describe.each(JUDGED)('%s', (type) => {
97+
it.each([...NEWLY_REFUSED, ...ALREADY_REFUSED])('refuses %s with invalid_number, on insert and update', (_label, value) => {
98+
for (const mode of ['insert', 'update'] as const) {
99+
expect(answer(type, value, mode), mode).toEqual([[`f_${type}`, 'invalid_number']]);
100+
}
101+
});
102+
103+
it.each(ACCEPTED)('accepts the number %s, on insert and update', (_label, value) => {
104+
for (const mode of ['insert', 'update'] as const) {
105+
expect(answer(type, value, mode), mode).toBeNull();
106+
}
107+
});
108+
});
109+
110+
it("refuses exactly what the spec's stored value schema refuses, over every non-string input", () => {
111+
// `valueSchemaFor` is the spec's declaration of the stored value for the
112+
// type. Off the string half, the arm and the declaration must not disagree.
113+
for (const type of JUDGED) {
114+
const declared = valueSchemaFor({ type }, 'stored');
115+
for (const [label, value] of [...NEWLY_REFUSED, ...ALREADY_REFUSED, ...ACCEPTED]) {
116+
if (typeof value === 'string') continue;
117+
const specAccepts = declared.safeParse(value).success;
118+
expect(answer(type, value, 'insert') === null, `${type} ${label}`).toBe(specAccepts);
119+
}
120+
}
121+
});
122+
123+
it('UNCHANGED here: a string is still judged by Number(), as at base (the string half waits on #20336)', () => {
124+
// A characterization, not an endorsement: the spec's stored value schema
125+
// refuses every one of these. It turns red when the string half lands.
126+
for (const type of JUDGED) {
127+
for (const s of ['12', '12.5', '0x10', ' 12 ', '1e3']) {
128+
expect(answer(type, s, 'insert'), `${type} ${JSON.stringify(s)}`).toBeNull();
129+
}
130+
}
131+
});
132+
133+
it('CONTROL: a blank is still null before the arm, so it is never judged (#20308)', () => {
134+
for (const blank of ['', ' ']) {
135+
const row = normalizeBlankTypedValues(schemaOf(JUDGED), Object.fromEntries(JUDGED.map((t) => [`f_${t}`, blank])));
136+
for (const t of JUDGED) expect((row as Record<string, unknown>)[`f_${t}`], t).toBeNull();
137+
expect(() => validateRecord(schemaOf(JUDGED), row as Record<string, unknown>, 'insert')).not.toThrow();
138+
}
139+
});
140+
141+
it('CONTROL: summary is not judged by the arm (the seat ruling on #20308)', () => {
142+
expect(COMPUTED_VALUE_TYPES.has('summary')).toBe(true);
143+
for (const [, value] of [...NEWLY_REFUSED, ...ACCEPTED]) {
144+
expect(answer('summary', value, 'insert')).toBeNull();
145+
}
146+
});
147+
});

0 commit comments

Comments
 (0)