diff --git a/.changeset/20309-number-arm-non-string-refused.md b/.changeset/20309-number-arm-non-string-refused.md new file mode 100644 index 00000000000..7de539952ec --- /dev/null +++ b/.changeset/20309-number-arm-non-string-refused.md @@ -0,0 +1,57 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql)!: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with `invalid_number` (#20309) + +Clause-②: no (narrowing) + +**BREAKING**: shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner and the ADR-0087 disposition below, never by the level). The +narrowing: an array, a boolean or an object whose `Number()` is finite, such as +`[500]`, `[]`, `true` or `false`, written to one of those fields is now refused +with `400 VALIDATION_FAILED` / `invalid_number`. It used to be accepted and +stored as sent. + +## What was wrong + +The record validator judged `Number(value)` on a number-typed field, but the +write carried `value` itself. Every value that JavaScript coerces to a finite +number therefore passed the check and reached the driver unchanged: + +- **SQLite** stored `[500]` as the TEXT `'[500]'`, which a read returned as the + string `"[500]"`; `[]` as the TEXT `'[]'`; and `true` / `false` as `1` / `0`. +- **memory** stored the array or the boolean itself. + +`[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`. + +## What changes + +- On `number`, `currency`, `percent`, `rating`, `slider` and `progress`, a value + that is neither a number nor a string is refused with `invalid_number`: an + array, a boolean, a plain object, a `Date`. This holds on every engine, REST, + batch and import write door, because they all write through the same + validator. +- A number is judged and stored exactly as before, and so are the `min`, `max` + and `scale` checks and their messages. +- A string is also unchanged. It is still judged by `Number()` and stored as + sent. Which strings a number field accepts is a separate change. +- `summary` is still not judged by this check (it is in the spec's + `COMPUTED_VALUE_TYPES`). A blank still becomes `null` before the check runs. + +## Rows already stored + +This refuses new writes only; a stored value is never re-read by the check. +Rows written earlier on SQLite may hold such a value as TEXT in a numeric +column. To find them, run this once per number-typed column: + +```sql +SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; +``` + +OBJECT is the object name and FIELD is the field name. A match is a cell that +SQLite could not store as a number: an array written as TEXT, or a string such +as `'0x10'`. Decide its number by hand; nothing here rewrites it. + + diff --git a/packages/objectql/src/engine-number-value-door.test.ts b/packages/objectql/src/engine-number-value-door.test.ts new file mode 100644 index 00000000000..30c284095df --- /dev/null +++ b/packages/objectql/src/engine-number-value-door.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20309 — on every engine write door, an array, boolean or object on a number + * field is refused before the driver sees it. + * + * The arm used to judge `Number(value)` while the write carried `value`, so + * `[500]`, `[]` and `true` passed and reached the driver as sent. Measured on + * `origin/main` c74de10a94: memory stored each verbatim (the array, the + * boolean), and SQLite stored `'[500]'` / `'[]'` as TEXT and `true` as `1`. + * + * This file pins the DRIVER-FACING half on each door: a refused value never + * reaches the driver (no `create`, `bulkCreate`, `update` or `updateMany` + * call carries it), and an accepted number arrives as the same number + * (`Object.is`), so for a number what the arm judged is what the driver + * receives. Memory and MongoDB store exactly this payload. The SQL physical + * column is pinned in `packages/rest/src/rest-data-number-value.test.ts`. + * A string is not judged differently here: that half waits on #20336. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { ValidationError } from './validation/record-validator.js'; + +const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t)); +const f = (t: string) => `f_${t}`; + +/** The card's table and the non-string coercions it named: all refused. */ +const REFUSED: ReadonlyArray = [ + ['[500]', [500]], + ['[5, 7]', [5, 7]], + ['[]', []], + ['true', true], + ['false', false], + ['{}', {}], +]; + +interface Call { fn: string; rows: Record[] } + +function makeStubDriver() { + const calls: Call[] = []; + const rows = new Map>(); + let n = 0; + const put = (data: Record) => { + const row = { ...data, id: (data.id as string) ?? `r${++n}` }; + rows.set(row.id as string, row); + return row; + }; + const driver: any = { + name: 'stub', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find() { return [...rows.values()]; }, + async findOne(_o: string, q: any) { + const id = (q?.where ?? q?.filter ?? q)?.id; + return (typeof id === 'string' ? rows.get(id) : rows.values().next().value) ?? null; + }, + async count() { return rows.size; }, + async create(_o: string, data: Record) { + calls.push({ fn: 'create', rows: [{ ...data }] }); + return put(data); + }, + async bulkCreate(_o: string, list: Record[]) { + calls.push({ fn: 'bulkCreate', rows: list.map((r) => ({ ...r })) }); + return list.map(put); + }, + async update(_o: string, id: string, data: Record) { + calls.push({ fn: 'update', rows: [{ ...data }] }); + return put({ ...(rows.get(id) ?? {}), ...data, id }); + }, + async updateMany(_o: string, _ast: unknown, data: Record) { + calls.push({ fn: 'updateMany', rows: [{ ...data }] }); + return rows.size; + }, + async upsert(o: string, data: Record) { return this.create(o, data); }, + async delete() { return true; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, calls }; +} + +const OBJ = { + name: 'num_door', + label: 'Number door', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + ...Object.fromEntries(JUDGED.map((t) => [f(t), { name: f(t), type: t }])), + }, +}; + +async function refusal(fn: () => Promise) { + try { + await fn(); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + return { code: (e as ValidationError).code, fields: (e as ValidationError).fields.map((x) => [x.field, x.code]) }; + } + return null; +} + +describe('engine write doors: the number arm judges what the driver receives (#20309)', () => { + let engine: ObjectQL; + let stub: ReturnType; + + beforeEach(async () => { + stub = makeStubDriver(); + engine = new ObjectQL(); + engine.registerDriver(stub.driver, true); + await engine.init(); + engine.registry.registerObject(OBJ as any); + }); + + /** Every value any driver write call carried for `field`. */ + const written = (field: string) => + stub.calls.flatMap((c) => c.rows).filter((r) => field in r).map((r) => r[field]); + + describe.each(JUDGED)('%s', (type) => { + it.each(REFUSED)('%s is refused on insert, insert([...]), update by id and update by predicate, and never reaches the driver', async (_l, value) => { + await engine.insert('num_door', { id: 'seed', [f(type)]: 1 }); + stub.calls.length = 0; + const expected = { code: 'VALIDATION_FAILED', fields: [[f(type), 'invalid_number']] }; + + expect(await refusal(() => engine.insert('num_door', { id: 'a', [f(type)]: structuredClone(value) }))).toEqual(expected); + expect(await refusal(() => engine.insert('num_door', [{ id: 'b', [f(type)]: structuredClone(value) }]))).toEqual(expected); + expect(await refusal(() => engine.update('num_door', { id: 'seed', [f(type)]: structuredClone(value) }))).toEqual(expected); + expect(await refusal(() => engine.update('num_door', { [f(type)]: structuredClone(value) }, { where: { id: { $in: ['seed'] } }, multi: true } as any))).toEqual(expected); + + const outcomes = await engine.insertMany('num_door', [{ id: 'm', [f(type)]: structuredClone(value) }]); + expect(outcomes.map((o) => o.ok)).toEqual([false]); + + expect(written(f(type))).toEqual([]); + }); + }); + + it('the dry run agrees with the write', async () => { + const refused = await engine.validate('num_door', { id: 'p1', f_number: [500] }); + expect(refused.valid).toBe(false); + expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['f_number', 'invalid_number']]); + expect((await engine.validate('num_door', { id: 'p2', f_number: 500 })).valid).toBe(true); + }); + + it('CONTROL: a finite number reaches the driver as the same number, on insert and update', async () => { + const VALID = [500, 12.5, 0, -3]; + for (const type of JUDGED) { + for (const v of VALID) { + stub.calls.length = 0; + await engine.insert('num_door', { id: `v_${type}_${v}`, [f(type)]: v }); + await engine.update('num_door', { id: `v_${type}_${v}`, [f(type)]: v }); + const got = written(f(type)); + expect(got, `${type} ${v}`).toHaveLength(2); + for (const g of got) expect(Object.is(g, v), `${type} ${v}`).toBe(true); + } + } + }); +}); diff --git a/packages/objectql/src/validation/record-validator.number-value.test.ts b/packages/objectql/src/validation/record-validator.number-value.test.ts new file mode 100644 index 00000000000..f343ff45255 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.number-value.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20309 — the number arm refuses a value that is neither a number nor a + * string: an array, a boolean, an object. + * + * ## The defect + * + * The arm judged `Number(value)` and the write carried `value`. So every value + * JS coerces to a finite number passed the check and reached the driver as it + * was sent. Measured on `origin/main` c74de10a94 through the real engine and + * REST doors: + * + * - `[500]`: SQLite stored the TEXT `'[500]'` and memory stored the array; + * - `[]`: SQLite stored `'[]'` and memory stored the array; + * - `true` / `false`: SQLite stored `1` / `0` and memory stored the boolean. + * + * `[5, 7]` and `{}` were already refused, because `Number()` of each is `NaN`. + * + * ## What this file pins + * + * - The refusal set on every judged type, on insert and update, as the + * `VALIDATION_FAILED` envelope with `invalid_number`. + * - Parity with the spec over every non-string input: the arm refuses exactly + * what the spec's stored value schema for the type (`valueSchemaFor`, + * `z.number().finite()`) refuses. + * - The controls: a finite number passes, including `0` and a negative; a + * blank is still `null` before the arm (#20308); `summary` is still not + * judged (the seat ruling on #20308). + * - The STRING half is unchanged: a string is still judged by `Number()`, as + * at base. Which strings a number field accepts waits on the producer census + * and the spec's numeric grammar (#20336). The characterization below turns + * red when that half lands, on purpose. + * + * The driver-facing half (what reaches the driver on each engine door) is + * `../engine-number-value-door.test.ts`. The physical column, through REST on + * SQLite, is `packages/rest/src/rest-data-number-value.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; +import { normalizeBlankTypedValues, validateRecord, ValidationError } from './record-validator.js'; + +const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t)); + +/** Non-strings JS coerces to a finite number, which the old arm let through. */ +const NEWLY_REFUSED: ReadonlyArray = [ + ['[500]', [500]], + ['[]', []], + ['[\'12\']', ['12']], + ['true', true], + ['false', false], + ['a Date', new Date(0)], + ['a Number object', Object(7)], +]; +/** Values the old arm already refused; their answer is unchanged. */ +const ALREADY_REFUSED: ReadonlyArray = [ + ['[5, 7]', [5, 7]], + ['{}', {}], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ["'Infinity'", 'Infinity'], + ["'abc'", 'abc'], +]; +const ACCEPTED: ReadonlyArray = [ + ['500', 500], + ['12.5', 12.5], + ['0', 0], + ['-3', -3], + ['1e21', 1e21], +]; + +function schemaOf(types: readonly string[]) { + return { fields: Object.fromEntries(types.map((t) => [`f_${t}`, { name: `f_${t}`, type: t }])) } as any; +} + +/** The field-level answer, or `null` when the write is accepted. */ +function answer(type: string, value: unknown, mode: 'insert' | 'update') { + try { + validateRecord(schemaOf([type]), { [`f_${type}`]: value }, mode); + return null; + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).code).toBe('VALIDATION_FAILED'); + return (e as ValidationError).fields.map((x) => [x.field, x.code]); + } +} + +describe('the number arm: an array, boolean or object is invalid_number (#20309)', () => { + it('the judged population is the spec numeric class minus the computed class: the six types', () => { + // A control on the set itself: if it emptied, every case below would pass + // over nothing. + expect([...JUDGED].sort()).toEqual(['currency', 'number', 'percent', 'progress', 'rating', 'slider']); + }); + + describe.each(JUDGED)('%s', (type) => { + it.each([...NEWLY_REFUSED, ...ALREADY_REFUSED])('refuses %s with invalid_number, on insert and update', (_label, value) => { + for (const mode of ['insert', 'update'] as const) { + expect(answer(type, value, mode), mode).toEqual([[`f_${type}`, 'invalid_number']]); + } + }); + + it.each(ACCEPTED)('accepts the number %s, on insert and update', (_label, value) => { + for (const mode of ['insert', 'update'] as const) { + expect(answer(type, value, mode), mode).toBeNull(); + } + }); + }); + + it("refuses exactly what the spec's stored value schema refuses, over every non-string input", () => { + // `valueSchemaFor` is the spec's declaration of the stored value for the + // type. Off the string half, the arm and the declaration must not disagree. + for (const type of JUDGED) { + const declared = valueSchemaFor({ type }, 'stored'); + for (const [label, value] of [...NEWLY_REFUSED, ...ALREADY_REFUSED, ...ACCEPTED]) { + if (typeof value === 'string') continue; + const specAccepts = declared.safeParse(value).success; + expect(answer(type, value, 'insert') === null, `${type} ${label}`).toBe(specAccepts); + } + } + }); + + it('UNCHANGED here: a string is still judged by Number(), as at base (the string half waits on #20336)', () => { + // A characterization, not an endorsement: the spec's stored value schema + // refuses every one of these. It turns red when the string half lands. + for (const type of JUDGED) { + for (const s of ['12', '12.5', '0x10', ' 12 ', '1e3']) { + expect(answer(type, s, 'insert'), `${type} ${JSON.stringify(s)}`).toBeNull(); + } + } + }); + + it('CONTROL: a blank is still null before the arm, so it is never judged (#20308)', () => { + for (const blank of ['', ' ']) { + const row = normalizeBlankTypedValues(schemaOf(JUDGED), Object.fromEntries(JUDGED.map((t) => [`f_${t}`, blank]))); + for (const t of JUDGED) expect((row as Record)[`f_${t}`], t).toBeNull(); + expect(() => validateRecord(schemaOf(JUDGED), row as Record, 'insert')).not.toThrow(); + } + }); + + it('CONTROL: summary is not judged by the arm (the seat ruling on #20308)', () => { + expect(COMPUTED_VALUE_TYPES.has('summary')).toBe(true); + for (const [, value] of [...NEWLY_REFUSED, ...ACCEPTED]) { + expect(answer('summary', value, 'insert')).toBeNull(); + } + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index ea2e47c72f0..14e67988676 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -25,6 +25,9 @@ * - `valueDomain` a declared standard domain's membership, judged by the * spec's shared `isValueDomainMember` — the WRITTEN value * only (#14168, maintainer ruling 2026-09-02 option A) + * - number types an array, boolean or object is `invalid_number`, never + * coerced (#20309); a number, or a string by `Number()`, + * must be finite * - `min` / `max` (number/currency/percent/rating/slider) * - `scale` more decimal places than the field's STORED allowance → * `max_scale` (#7501; rejection, NEVER rounding — @@ -857,7 +860,26 @@ function validateOne( // failed with `ERR_SUMMARY_RECOMPUTE` on memory and SQLite. A blank on a // `summary` is still `null` at the door (`normalizeBlankTypedValues` reads the // whole numeric class). + // + // [#20309] A value that is neither a number nor a string is refused: an + // array, a boolean, a plain object, a `Date`. The arm used to judge + // `Number(value)` on every value while the write carried `value`, so each of + // these that JS coerces to a finite number passed and reached the driver as + // sent: `[500]` (SQLite stored the TEXT `'[500]'`, memory the array), `[]`, + // `true` / `false`. None is the spec's stored value for this class + // (`valueSchemaFor`: `z.number().finite()`) and none has a numeric reading + // to parse, so the arm refuses it and never silently alters it (the #7501 + // posture). A number is judged as itself and written as itself. + // + // ⛔ A STRING is still judged by `Number()` and written as sent, exactly as + // before this change. Which strings a number field accepts is a separate + // decision: it waits on the producer census and on the platform's one + // numeric grammar, which belongs to `@objectstack/spec` (#20336), never to a + // second copy here. if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) { + if (typeof value !== 'number' && typeof value !== 'string') { + return fail('invalid_number'); + } const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) { return fail('invalid_number'); diff --git a/packages/rest/src/rest-data-number-value.test.ts b/packages/rest/src/rest-data-number-value.test.ts new file mode 100644 index 00000000000..e1209fc83e1 --- /dev/null +++ b/packages/rest/src/rest-data-number-value.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20309 — a number field refuses an array, a boolean or an object at every + * REST write door, on a real engine (`ObjectQL` + sqlite `SqlDriver`) and the + * real `RestServer` routes (the harness `rest-data-blank-typed-value.test.ts` + * boots). + * + * Measured on `origin/main` c74de10a94 with this harness: `POST /data/:object` + * with `[500]` on a number field answered 201, and SQLite stored the TEXT + * `'[500]'`, which `GET` returned as the string `"[500]"`. `[]` stored `'[]'`, + * and `true` / `false` stored `1` / `0`. Every one of those now answers + * `400 VALIDATION_FAILED` with the field code `invalid_number`, and no row is + * written or changed. + * + * The PHYSICAL column is read with the driver's own query builder and SQLite's + * `typeof()`, past every engine read coercion. + * + * Controls: `[5, 7]` and `{}` were already refused and still are; a JS number + * is stored as a SQLite `real` (`integer` on `rating`) and read back + * unchanged; a blank is still stored as `null` (#20308). A string is not + * judged differently here: that half waits on #20336. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t)); +const f = (t: string) => `f_${t}`; + +const OBJ = { + name: 'num_rest', label: 'Number', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + ...Object.fromEntries(JUDGED.map((t) => [f(t), { name: f(t), type: t }])), + }, +}; + +/** The card's table and the non-string coercions it named. */ +const REFUSED: ReadonlyArray = [ + ['[500]', [500]], + ['[5, 7]', [5, 7]], + ['[]', []], + ['true', true], + ['false', false], + ['{}', {}], +]; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(OBJ as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { + api: { requireAuth: false }, batch: { enableBatchEndpoint: true }, + } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const call = async (method: string, path: string, params: Record, body: unknown) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + /** The physical cell and its SQLite storage class, read past every engine read coercion. */ + const cell = async (id: string, col: string) => { + const rows = await (driver as any).knex.raw(`select "${col}" as v, typeof("${col}") as c from "num_rest" where id = ?`, [id]); + return rows[0] as { v: unknown; c: string } | undefined; + }; + return { engine, call, cell }; +} + +describe('REST write doors on SQLite: a number field refuses a non-number (#20309)', () => { + let ctx: Awaited>; + beforeEach(async () => { ctx = await boot(); }); + + const expectRefused = (res: { status: number; body: any }, field: string) => { + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res.body.fields.map((x: any) => [x.field, x.code])).toEqual([[field, 'invalid_number']]); + }; + const expectRowRefused = (res: { status: number; body: any }) => { + expect(res.body.results.map((r: any) => [r.success, r.errors?.[0]?.code])).toEqual([[false, 'VALIDATION_FAILED']]); + }; + + describe.each(JUDGED)('%s', (type) => { + const col = f(type); + + it.each(REFUSED)('%s: POST and batch create write no row; PATCH, batch update and updateMany leave the stored number', async (_l, value) => { + expectRefused(await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'c1', [col]: structuredClone(value) }), col); + expect(await ctx.cell('c1', col)).toBeUndefined(); + + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'create', records: [{ data: { id: 'c2', [col]: structuredClone(value) } }] })); + expect(await ctx.cell('c2', col)).toBeUndefined(); + + await ctx.engine.insert('num_rest', { id: 'u1', [col]: 7 }); + expectRefused(await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id: 'u1' }, { [col]: structuredClone(value) }), col); + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' }, + { operation: 'update', records: [{ id: 'u1', data: { [col]: structuredClone(value) } }] })); + expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/updateMany', { object: 'num_rest' }, + { records: [{ id: 'u1', data: { [col]: structuredClone(value) } }] })); + expect((await ctx.cell('u1', col))?.v).toBe(7); + }); + + it('CONTROL: a JS number is stored as a number and read back unchanged, through POST and PATCH', async () => { + const created = await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'n1', [col]: 500 }); + expect(created.status).toBe(201); + expect(await ctx.cell('n1', col)).toEqual({ v: 500, c: type === 'rating' ? 'integer' : 'real' }); + const patched = await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id: 'n1' }, { [col]: 12.5 }); + expect(patched.status).toBe(200); + expect(await ctx.cell('n1', col)).toEqual({ v: 12.5, c: 'real' }); + const read = await ctx.engine.findOne('num_rest', { where: { id: 'n1' } }) as Record; + expect(read[col]).toBe(12.5); + }); + }); + + it('CONTROL: a blank is still stored as null (#20308)', async () => { + const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'e1', ...Object.fromEntries(JUDGED.map((t) => [f(t), ''])) }); + expect(res.status).toBe(201); + for (const t of JUDGED) expect(await ctx.cell('e1', f(t)), t).toEqual({ v: null, c: 'null' }); + }); +});