From 725c10c03485689172147c98c9a3ef4967027079 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:29:44 +0000 Subject: [PATCH 1/5] fix(objectql)!: the number arm accepts only a finite JS number (#20309) The arm judged Number(value) and the write carried value, so [500], [], true, '0x10', ' 12 ' and '12' passed and reached the driver as sent. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/validation/record-validator.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index ea2e47c72f0..92bda2b5dda 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 the value must be a finite JS number, the spec's stored + * value; a string, array or boolean is `invalid_number`, + * never coerced (#20309) * - `min` / `max` (number/currency/percent/rating/slider) * - `scale` more decimal places than the field's STORED allowance → * `max_scale` (#7501; rejection, NEVER rounding — @@ -857,11 +860,25 @@ 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] ONLY a finite JS number passes: the spec's stored value for this + // class, `valueSchemaFor`'s `z.number().finite()`. The arm judges the value + // the driver receives, and the driver receives exactly what was sent, since + // nothing between here and the driver rewrites a numeric value. The arm used + // to judge `Number(value)` instead, so every value JS coerces to a finite + // number passed and was then written as sent: `[500]` (SQLite stored the + // TEXT `'[500]'`, memory the array), `[]`, `true` / `false`, `'0x10'`, + // `' 12 '`, `'1e3'`, and a plain `'12'` (memory stored the string). + // ⛔ No coercion here: refuse, never silently alter (the #7501 posture). A + // write door that parses a string into a number is a second dialect of the + // value contract. A producer that holds a string converts it itself, as the + // import route does (`parseNumberCell` in `@objectstack/rest`) before the + // engine sees the row. if (NUMERIC_VALUE_TYPES.has(t) && !COMPUTED_VALUE_TYPES.has(t)) { - const n = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(n)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { return fail('invalid_number'); } + const n = value; // [#20308] `progress` joined the TYPE check above, and only that. The // bounds and `scale` below keep the five types they always read: `scale`'s // own contract names the types it is enforced on (`number`, `percent`, From 983b8667ec7e3dce3df7b66f0a2eb228712dc373 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:36:59 +0000 Subject: [PATCH 2/5] test(objectql): pin the number arm's refusal set and the driver-facing payload (#20309) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/engine-number-value-door.test.ts | 157 ++++++++++++++++++ .../record-validator.number-value.test.ts | 140 ++++++++++++++++ .../src/validation/record-validator.test.ts | 28 +++- 3 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 packages/objectql/src/engine-number-value-door.test.ts create mode 100644 packages/objectql/src/validation/record-validator.number-value.test.ts 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..b6cccce2b77 --- /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, what the number arm judged is what the + * driver receives. + * + * The arm used to judge `Number(value)` while the write carried `value`, so + * `[500]`, `[]`, `true`, `'0x10'`, `' 12 '` and `'12'` passed and reached the + * driver as sent. Measured on `origin/main` c74de10a94: memory stored each + * verbatim (the array, the boolean, the string), and SQLite stored `'[500]'` / + * `'[]'` / `'0x10'` 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`). Memory and MongoDB store exactly this payload. The SQL + * physical column is pinned in `packages/rest/src/rest-data-number-value.test.ts`. + */ + +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 coercions it named, all refused now. */ +const REFUSED: ReadonlyArray = [ + ['[500]', [500]], + ['[5, 7]', [5, 7]], + ['[]', []], + ['true', true], + ["'0x10'", '0x10'], + ["' 12 '", ' 12 '], + ["'12'", '12'], +]; + +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..1345e2b81d6 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.number-value.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20309 — the number arm accepts only a finite JS number, the value the + * driver will then receive. + * + * ## 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 `'[]'`; + * - `true` / `false`: SQLite stored `1` / `0`, and memory stored the boolean; + * - `'0x10'`: SQLite stored the TEXT `'0x10'`, which its read repair presents + * as `16`; + * - `' 12 '`, `'12'`, `'1e3'`: memory stored the string. + * + * `[5, 7]`, `{}` and `'Infinity'` were refused, because `Number()` of each is + * not finite. + * + * ## 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: the arm refuses exactly what the spec's stored value + * schema for the type (`valueSchemaFor`, `z.number().finite()`) refuses, over + * the whole grid. + * - 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 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)); + +/** Values JS coerces to a finite number, which the old arm let through. */ +const COERCIBLE: ReadonlyArray = [ + ['[500]', [500]], + ['[]', []], + ['true', true], + ['false', false], + ["'0x10'", '0x10'], + ["' 12 '", ' 12 '], + ["'12'", '12'], + ["'12.5'", '12.5'], + ["'-3'", '-3'], + ["'1e3'", '1e3'], + ['a Date', new Date(0)], +]; +/** Values the old arm already refused; their answer is unchanged. */ +const ALREADY_REFUSED: ReadonlyArray = [ + ['[5, 7]', [5, 7]], + ['{}', {}], + ["'Infinity'", 'Infinity'], + ["'abc'", 'abc'], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], +]; +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: only a finite JS number passes (#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([...COERCIBLE, ...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 the whole grid", () => { + // `valueSchemaFor` is the spec's declaration of the stored value for the + // type. The arm and the declaration must not disagree on any input. + for (const type of JUDGED) { + const declared = valueSchemaFor({ type }, 'stored'); + for (const [label, value] of [...COERCIBLE, ...ALREADY_REFUSED, ...ACCEPTED]) { + const specAccepts = declared.safeParse(value).success; + expect(answer(type, value, 'insert') === null, `${type} ${label}`).toBe(specAccepts); + } + } + }); + + 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 [...COERCIBLE, ...ACCEPTED]) { + expect(answer('summary', value, 'insert')).toBeNull(); + } + }); +}); diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index a548a4fe237..ce817775064 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -950,10 +950,19 @@ describe('validateRecord — number `scale` is enforced by rejection (#7501)', ( expect(err).toMatchObject({ field: 'work_hours', code: 'max_scale' }); }); - it('string-carried numbers (a CSV cell) are judged after coercion, same as min/max', () => { - const [err] = fieldsOf({ work_hours: '11.5' }); + it('a string-carried number is refused, never coerced (#20309): scale judges the number a producer sends', () => { + // A CSV cell reaches the engine as a NUMBER: the import route converts it + // (`parseNumberCell`) before the row is written. A string that gets here + // was sent as a string, and the arm judges what the driver would store. + expect(fieldsOf({ work_hours: '11.5' })).toEqual([ + expect.objectContaining({ field: 'work_hours', code: 'invalid_number' }), + ]); + expect(fieldsOf({ work_hours: '11' })).toEqual([ + expect.objectContaining({ field: 'work_hours', code: 'invalid_number' }), + ]); + const [err] = fieldsOf({ work_hours: 11.5 }); expect(err).toMatchObject({ code: 'max_scale', constraint: { scale: 0, actual: 1 } }); - expect(() => validateRecord(schema, { work_hours: '11' }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { work_hours: 11 }, 'insert')).not.toThrow(); }); it('exponent forms are normalized, not read as zero decimals', () => { @@ -1251,10 +1260,12 @@ describe('validateRecord — a fraction-stored percent derives `scale + 2` (#193 expect(fieldsOf(s, { rate: 0.123456789 })).toBeNull(); }); - it('string-carried and exponent forms travel with the derivation', () => { - // A CSV cell reaches the branch as a string and is judged after coercion; + it('the derivation judges the number a producer sends; exponent forms travel with it', () => { + // A CSV cell arrives as the number the import route parsed (#20309: a + // string that reaches the arm is `invalid_number`, never coerced); // `1e-6` is six places, one past a scale: 2 fraction field's four. - expect(fieldsOf(fraction, { rate: '0.1234' })).toBeNull(); + expect(fieldsOf(fraction, { rate: 0.1234 })).toBeNull(); + expect(fieldsOf(fraction, { rate: '0.1234' })?.[0]).toMatchObject({ code: 'invalid_number' }); expect(fieldsOf(fraction, { rate: 1e-6 })?.[0]).toMatchObject({ code: 'max_scale', constraint: { scale: 4, actual: 6 }, @@ -1313,8 +1324,9 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (# const legacy = { fields: { amount: { type: 'currency', label: 'Amount', scale: 2 } } }; expect(fieldsOf(legacy, { amount: 1.23456 })).toBeNull(); expect(fieldsOf(legacy, { amount: 1.23456 }, 'update')).toBeNull(); - // A string-carried amount (a CSV cell) takes the same path after coercion. - expect(fieldsOf(legacy, { amount: '1.23456' })).toBeNull(); + // A string-carried amount is refused, never coerced (#20309): a CSV cell + // reaches the engine as the number the import route parsed. + expect(fieldsOf(legacy, { amount: '1.23456' })?.[0]).toMatchObject({ field: 'amount', code: 'invalid_number' }); }); it('keeps the rest of the numeric branch on currency — min, max and the finite-number check', () => { From 99b26ca79a40d10388742611fd3380eb6b856212 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:38:11 +0000 Subject: [PATCH 3/5] test(rest): a number field refuses a non-number at every REST write door on SQLite (#20309) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../rest/src/rest-data-number-value.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 packages/rest/src/rest-data-number-value.test.ts 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..372b93c909b --- /dev/null +++ b/packages/rest/src/rest-data-number-value.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20309 — a number field refuses a non-number 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 `'[]'`, + * `true` stored `1`, and `'0x10'` stored the TEXT `'0x10'` (read back as + * `16`). 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, because the read repair makes a + * stored TEXT `'0x10'` look like the number `16`. + * + * Controls: `[5, 7]` was already refused and still is; a JS number is stored as + * a SQLite `real` (`integer` on `rating`) and read back unchanged; a blank is + * still stored as `null` (#20308). + */ + +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 coercions it named. */ +const REFUSED: ReadonlyArray = [ + ['[500]', [500]], + ['[5, 7]', [5, 7]], + ['[]', []], + ['true', true], + ["'0x10'", '0x10'], + ["' 12 '", ' 12 '], +]; + +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' }); + }); +}); From 1e1b8f8f1124e13aeda6dc50a02a6201f6a7e5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:49:34 +0000 Subject: [PATCH 4/5] fix(objectql): narrow the #20309 change to non-string refusals; the string half waits on the spec grammar Arrays, booleans and objects are refused with invalid_number; a string is judged by Number() exactly as before (seat update: census branch b). Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/engine-number-value-door.test.ts | 24 +++---- .../record-validator.number-value.test.ts | 63 ++++++++++--------- .../src/validation/record-validator.test.ts | 28 +++------ .../src/validation/record-validator.ts | 41 ++++++------ .../rest/src/rest-data-number-value.test.ts | 29 ++++----- 5 files changed, 93 insertions(+), 92 deletions(-) diff --git a/packages/objectql/src/engine-number-value-door.test.ts b/packages/objectql/src/engine-number-value-door.test.ts index b6cccce2b77..30c284095df 100644 --- a/packages/objectql/src/engine-number-value-door.test.ts +++ b/packages/objectql/src/engine-number-value-door.test.ts @@ -1,20 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #20309 — on every engine write door, what the number arm judged is what the - * driver receives. + * #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]`, `[]`, `true`, `'0x10'`, `' 12 '` and `'12'` passed and reached the - * driver as sent. Measured on `origin/main` c74de10a94: memory stored each - * verbatim (the array, the boolean, the string), and SQLite stored `'[500]'` / - * `'[]'` / `'0x10'` as TEXT and `true` as `1`. + * `[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`). Memory and MongoDB store exactly this payload. The SQL - * physical column is pinned in `packages/rest/src/rest-data-number-value.test.ts`. + * (`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'; @@ -25,15 +26,14 @@ 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 coercions it named, all refused now. */ +/** The card's table and the non-string coercions it named: all refused. */ const REFUSED: ReadonlyArray = [ ['[500]', [500]], ['[5, 7]', [5, 7]], ['[]', []], ['true', true], - ["'0x10'", '0x10'], - ["' 12 '", ' 12 '], - ["'12'", '12'], + ['false', false], + ['{}', {}], ]; interface Call { fn: string; rows: Record[] } diff --git a/packages/objectql/src/validation/record-validator.number-value.test.ts b/packages/objectql/src/validation/record-validator.number-value.test.ts index 1345e2b81d6..f343ff45255 100644 --- a/packages/objectql/src/validation/record-validator.number-value.test.ts +++ b/packages/objectql/src/validation/record-validator.number-value.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #20309 — the number arm accepts only a finite JS number, the value the - * driver will then receive. + * #20309 — the number arm refuses a value that is neither a number nor a + * string: an array, a boolean, an object. * * ## The defect * @@ -12,25 +12,25 @@ * REST doors: * * - `[500]`: SQLite stored the TEXT `'[500]'` and memory stored the array; - * - `[]`: SQLite stored `'[]'`; - * - `true` / `false`: SQLite stored `1` / `0`, and memory stored the boolean; - * - `'0x10'`: SQLite stored the TEXT `'0x10'`, which its read repair presents - * as `16`; - * - `' 12 '`, `'12'`, `'1e3'`: memory stored the string. + * - `[]`: SQLite stored `'[]'` and memory stored the array; + * - `true` / `false`: SQLite stored `1` / `0` and memory stored the boolean. * - * `[5, 7]`, `{}` and `'Infinity'` were refused, because `Number()` of each is - * not finite. + * `[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: the arm refuses exactly what the spec's stored value - * schema for the type (`valueSchemaFor`, `z.number().finite()`) refuses, over - * the whole grid. + * - 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 @@ -43,28 +43,24 @@ import { normalizeBlankTypedValues, validateRecord, ValidationError } from './re const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t)); -/** Values JS coerces to a finite number, which the old arm let through. */ -const COERCIBLE: ReadonlyArray = [ +/** 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], - ["'0x10'", '0x10'], - ["' 12 '", ' 12 '], - ["'12'", '12'], - ["'12.5'", '12.5'], - ["'-3'", '-3'], - ["'1e3'", '1e3'], ['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]], ['{}', {}], - ["'Infinity'", 'Infinity'], - ["'abc'", 'abc'], ['NaN', Number.NaN], ['Infinity', Number.POSITIVE_INFINITY], + ["'Infinity'", 'Infinity'], + ["'abc'", 'abc'], ]; const ACCEPTED: ReadonlyArray = [ ['500', 500], @@ -90,7 +86,7 @@ function answer(type: string, value: unknown, mode: 'insert' | 'update') { } } -describe('the number arm: only a finite JS number passes (#20309)', () => { +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. @@ -98,7 +94,7 @@ describe('the number arm: only a finite JS number passes (#20309)', () => { }); describe.each(JUDGED)('%s', (type) => { - it.each([...COERCIBLE, ...ALREADY_REFUSED])('refuses %s with invalid_number, on insert and update', (_label, value) => { + 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']]); } @@ -111,18 +107,29 @@ describe('the number arm: only a finite JS number passes (#20309)', () => { }); }); - it("refuses exactly what the spec's stored value schema refuses, over the whole grid", () => { + 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. The arm and the declaration must not disagree on any input. + // 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 [...COERCIBLE, ...ALREADY_REFUSED, ...ACCEPTED]) { + 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]))); @@ -133,7 +140,7 @@ describe('the number arm: only a finite JS number passes (#20309)', () => { 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 [...COERCIBLE, ...ACCEPTED]) { + for (const [, value] of [...NEWLY_REFUSED, ...ACCEPTED]) { expect(answer('summary', value, 'insert')).toBeNull(); } }); diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index ce817775064..a548a4fe237 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -950,19 +950,10 @@ describe('validateRecord — number `scale` is enforced by rejection (#7501)', ( expect(err).toMatchObject({ field: 'work_hours', code: 'max_scale' }); }); - it('a string-carried number is refused, never coerced (#20309): scale judges the number a producer sends', () => { - // A CSV cell reaches the engine as a NUMBER: the import route converts it - // (`parseNumberCell`) before the row is written. A string that gets here - // was sent as a string, and the arm judges what the driver would store. - expect(fieldsOf({ work_hours: '11.5' })).toEqual([ - expect.objectContaining({ field: 'work_hours', code: 'invalid_number' }), - ]); - expect(fieldsOf({ work_hours: '11' })).toEqual([ - expect.objectContaining({ field: 'work_hours', code: 'invalid_number' }), - ]); - const [err] = fieldsOf({ work_hours: 11.5 }); + it('string-carried numbers (a CSV cell) are judged after coercion, same as min/max', () => { + const [err] = fieldsOf({ work_hours: '11.5' }); expect(err).toMatchObject({ code: 'max_scale', constraint: { scale: 0, actual: 1 } }); - expect(() => validateRecord(schema, { work_hours: 11 }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { work_hours: '11' }, 'insert')).not.toThrow(); }); it('exponent forms are normalized, not read as zero decimals', () => { @@ -1260,12 +1251,10 @@ describe('validateRecord — a fraction-stored percent derives `scale + 2` (#193 expect(fieldsOf(s, { rate: 0.123456789 })).toBeNull(); }); - it('the derivation judges the number a producer sends; exponent forms travel with it', () => { - // A CSV cell arrives as the number the import route parsed (#20309: a - // string that reaches the arm is `invalid_number`, never coerced); + it('string-carried and exponent forms travel with the derivation', () => { + // A CSV cell reaches the branch as a string and is judged after coercion; // `1e-6` is six places, one past a scale: 2 fraction field's four. - expect(fieldsOf(fraction, { rate: 0.1234 })).toBeNull(); - expect(fieldsOf(fraction, { rate: '0.1234' })?.[0]).toMatchObject({ code: 'invalid_number' }); + expect(fieldsOf(fraction, { rate: '0.1234' })).toBeNull(); expect(fieldsOf(fraction, { rate: 1e-6 })?.[0]).toMatchObject({ code: 'max_scale', constraint: { scale: 4, actual: 6 }, @@ -1324,9 +1313,8 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (# const legacy = { fields: { amount: { type: 'currency', label: 'Amount', scale: 2 } } }; expect(fieldsOf(legacy, { amount: 1.23456 })).toBeNull(); expect(fieldsOf(legacy, { amount: 1.23456 }, 'update')).toBeNull(); - // A string-carried amount is refused, never coerced (#20309): a CSV cell - // reaches the engine as the number the import route parsed. - expect(fieldsOf(legacy, { amount: '1.23456' })?.[0]).toMatchObject({ field: 'amount', code: 'invalid_number' }); + // A string-carried amount (a CSV cell) takes the same path after coercion. + expect(fieldsOf(legacy, { amount: '1.23456' })).toBeNull(); }); it('keeps the rest of the numeric branch on currency — min, max and the finite-number check', () => { diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 92bda2b5dda..14e67988676 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -25,9 +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 the value must be a finite JS number, the spec's stored - * value; a string, array or boolean is `invalid_number`, - * never coerced (#20309) + * - 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 — @@ -861,24 +861,29 @@ function validateOne( // `summary` is still `null` at the door (`normalizeBlankTypedValues` reads the // whole numeric class). // - // [#20309] ONLY a finite JS number passes: the spec's stored value for this - // class, `valueSchemaFor`'s `z.number().finite()`. The arm judges the value - // the driver receives, and the driver receives exactly what was sent, since - // nothing between here and the driver rewrites a numeric value. The arm used - // to judge `Number(value)` instead, so every value JS coerces to a finite - // number passed and was then written as sent: `[500]` (SQLite stored the - // TEXT `'[500]'`, memory the array), `[]`, `true` / `false`, `'0x10'`, - // `' 12 '`, `'1e3'`, and a plain `'12'` (memory stored the string). - // ⛔ No coercion here: refuse, never silently alter (the #7501 posture). A - // write door that parses a string into a number is a second dialect of the - // value contract. A producer that holds a string converts it itself, as the - // import route does (`parseNumberCell` in `@objectstack/rest`) before the - // engine sees the row. + // [#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' || !Number.isFinite(value)) { + 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'); } - const n = value; // [#20308] `progress` joined the TYPE check above, and only that. The // bounds and `scale` below keep the five types they always read: `scale`'s // own contract names the types it is enforced on (`number`, `percent`, diff --git a/packages/rest/src/rest-data-number-value.test.ts b/packages/rest/src/rest-data-number-value.test.ts index 372b93c909b..e1209fc83e1 100644 --- a/packages/rest/src/rest-data-number-value.test.ts +++ b/packages/rest/src/rest-data-number-value.test.ts @@ -1,24 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #20309 — a number field refuses a non-number 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). + * #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 `'[]'`, - * `true` stored `1`, and `'0x10'` stored the TEXT `'0x10'` (read back as - * `16`). Every one of those now answers `400 VALIDATION_FAILED` with the field - * code `invalid_number`, and no row is written or changed. + * 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, because the read repair makes a - * stored TEXT `'0x10'` look like the number `16`. + * `typeof()`, past every engine read coercion. * - * Controls: `[5, 7]` was already refused and still is; a JS number is stored as - * a SQLite `real` (`integer` on `rating`) and read back unchanged; a blank is - * still stored as `null` (#20308). + * 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'; @@ -39,14 +40,14 @@ const OBJ = { }, }; -/** The card's table and the coercions it named. */ +/** The card's table and the non-string coercions it named. */ const REFUSED: ReadonlyArray = [ ['[500]', [500]], ['[5, 7]', [5, 7]], ['[]', []], ['true', true], - ["'0x10'", '0x10'], - ["' 12 '", ' 12 '], + ['false', false], + ['{}', {}], ]; const liveEngines: ObjectQL[] = []; From bf83ded05c35f5b8c134d0a30c1ad8fb5c408fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:51:34 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(changeset):=20objectql=20minor,=20BRE?= =?UTF-8?q?AKING,=20Clause-=E2=91=A1=20no=20(narrowing)=20for=20the=20numb?= =?UTF-8?q?er=20arm=20(#20309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../20309-number-arm-non-string-refused.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .changeset/20309-number-arm-non-string-refused.md 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. + +