From f6af80064de891912256a510d02398e73b3102f8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:28:46 +0800 Subject: [PATCH 1/2] fix(driver-sql): round-trip rating/slider/toggle with type fidelity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rating/slider/toggle (and progress) fell to the DDL switch `default` case → table.string, giving the column TEXT affinity. SQLite then coerced the written number/boolean to a string ('4' not 4, '1' not true): the value persisted but the JS type leaked on read. On a low-code platform where an AI authors arbitrary field types, a field that silently returns the wrong type is a runtime-fidelity trap the static gates and value-loss tests don't catch. - map rating/slider/progress → REAL (numeric) column - map toggle → boolean column + register it in booleanFields so the read path coerces stored 1/0 back to a real JS boolean - fold object-valued record/video/audio into the shared JSON_COLUMN_TYPES source, and drive the DDL default off that set so the column-type switch and isJsonField can no longer drift - driver-layer unit test (sql-driver-numeric-fidelity) + lift the xfail quarantine on rating/slider/toggle in the dogfood matrix Co-Authored-By: Claude Opus 4.8 --- .../test/field-zoo-roundtrip.dogfood.test.ts | 6 +- .../src/sql-driver-numeric-fidelity.test.ts | 130 ++++++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 51 ++++--- 3 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts diff --git a/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts index 95239ff1d9..21bf2ecb05 100644 --- a/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts +++ b/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts @@ -47,14 +47,14 @@ const MATRIX: FieldCase[] = [ { field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } }, { field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } }, { field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } }, - { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 }, xfail: true }, - { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 }, xfail: true }, + { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } }, + { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } }, // temporal — f_time is a #2004 fix (time-of-day) { field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } }, { field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } }, // logic { field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } }, - { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true }, xfail: true }, + { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } }, // scalar selection { field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } }, { field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } }, diff --git a/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts b/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts new file mode 100644 index 0000000000..fee8ead6ca --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Scalar field types must round-trip with TYPE fidelity, not just value + * survival. Regression (#field-zoo): `rating`/`slider`/`toggle`/`progress` + * were absent from the DDL column-type switch, so they fell to + * `default → table.string` and got TEXT affinity — SQLite then coerced the + * written number/boolean to a string ('4' not 4, '1' not true). The value + * persisted, so value-loss tests stayed green; only the JS type leaked, which + * is a runtime-fidelity trap on a low-code platform where an AI authors + * arbitrary field types. + * + * The fix maps `rating`/`slider`/`progress` to a REAL (numeric) column and + * `toggle` to a boolean column + the `booleanFields` read-coercion registry, + * and folds the object-valued `record`/`video`/`audio` into the shared + * `JSON_COLUMN_TYPES` source so they store/parse as JSON like `composite`. + * + * `number`/`currency`/`percent`/`boolean` are included as the working control: + * the SAME harness must keep returning them with correct types. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { + name: 'zoo', + fields: { + name: { type: 'string' }, + // control — these already round-tripped with correct types + f_number: { type: 'number' }, + f_currency: { type: 'currency' }, + f_percent: { type: 'percent' }, + f_boolean: { type: 'boolean' }, + // the leak: numeric scalars that used to land as TEXT + f_rating: { type: 'rating', max: 5 }, + f_slider: { type: 'slider', min: 0, max: 100, step: 5 }, + f_progress: { type: 'progress', min: 0, max: 100 }, + // the leak: a switch-rendered boolean + f_toggle: { type: 'toggle' }, + // object-valued types folded into JSON_COLUMN_TYPES + f_record: { type: 'record' }, + f_video: { type: 'video' }, + f_audio: { type: 'audio' }, + }, + }, + ]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('round-trips numeric scalars as numbers, not strings', async () => { + await driver.create( + 'zoo', + { + id: 'z1', + name: 'Specimen', + f_number: 42, + f_currency: 1234.56, + f_percent: 75, + f_rating: 4, + f_slider: 25, + f_progress: 60, + }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true }); + + // control + expect(row.f_number).toBe(42); + expect(row.f_currency).toBeCloseTo(1234.56, 5); + expect(row.f_percent).toBe(75); + + // the fix: these used to come back as '4' / '25' / '60' + expect(typeof row.f_rating).toBe('number'); + expect(row.f_rating).toBe(4); + expect(typeof row.f_slider).toBe('number'); + expect(row.f_slider).toBe(25); + expect(typeof row.f_progress).toBe('number'); + expect(row.f_progress).toBe(60); + }); + + it('round-trips toggle as a boolean, not 1/0 or a string', async () => { + await driver.create('zoo', { id: 'z2', name: 'B', f_boolean: true, f_toggle: true }, { bypassTenantAudit: true }); + await driver.create('zoo', { id: 'z3', name: 'C', f_boolean: false, f_toggle: false }, { bypassTenantAudit: true }); + + const on = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true }); + const off = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true }); + + // control + expect(on.f_boolean).toBe(true); + expect(off.f_boolean).toBe(false); + + // the fix: toggle used to come back as '1' / '0' + expect(typeof on.f_toggle).toBe('boolean'); + expect(on.f_toggle).toBe(true); + expect(typeof off.f_toggle).toBe('boolean'); + expect(off.f_toggle).toBe(false); + }); + + it('round-trips object-valued record/video/audio as objects, not strings', async () => { + await driver.create( + 'zoo', + { + id: 'z4', + name: 'D', + f_record: { home: '+1', work: '+2' }, + f_video: { url: 'https://cdn/v.mp4', duration: 12 }, + f_audio: { url: 'https://cdn/a.mp3', duration: 30 }, + }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('zoo', 'z4', { bypassTenantAudit: true }); + + expect(row.f_record).toEqual({ home: '+1', work: '+2' }); + expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 }); + expect(row.f_audio).toEqual({ url: 'https://cdn/a.mp3', duration: 30 }); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 1ad7aa1930..5711596c5c 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -41,12 +41,13 @@ const GLOBAL_TENANT = '__global__'; * the drift between them is exactly what let array-valued fields (multiselect/ * checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and * crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and - * null" (#field-zoo). `image`/`file`/`avatar` hold structured upload metadata; - * `composite`/`address`/`location` are objects; the rest are arrays. + * null" (#field-zoo). `image`/`file`/`avatar`/`video`/`audio` hold structured + * upload metadata; `composite`/`address`/`location`/`record` are objects; the + * rest are arrays. */ const JSON_COLUMN_TYPES = new Set([ - 'json', 'object', 'array', - 'image', 'file', 'avatar', + 'json', 'object', 'array', 'record', + 'image', 'file', 'avatar', 'video', 'audio', 'location', 'address', 'composite', 'multiselect', 'checkboxes', 'tags', 'repeater', 'vector', ]); @@ -1115,7 +1116,10 @@ export class SqlDriver implements IDataDriver { if (this.isJsonField(type, field)) { jsonCols.push(name); } - if (type === 'boolean') { + // `toggle` shares boolean storage/affinity, so it needs the same + // read coercion (stored 1/0 → JS true/false) or it leaks back as a + // number/string instead of a boolean (#field-zoo). + if (type === 'boolean' || type === 'toggle') { booleanCols.push(name); } if (type === 'date') { @@ -1841,9 +1845,23 @@ export class SqlDriver implements IDataDriver { case 'number': case 'currency': case 'percent': + // `rating`/`slider`/`progress` are authored as numeric scalars (a star + // count, a slider position, a percent-of-completion). Without an explicit + // case they fell to `default → table.string`, giving the column TEXT + // affinity so SQLite coerced the written number to a string ('4' not 4) — + // a silent type-fidelity leak the value-loss tests didn't catch. REAL + // affinity round-trips them as JS numbers (#field-zoo). + case 'rating': + case 'slider': + case 'progress': col = table.float(name); break; + // `toggle` is a boolean rendered as a switch. Same leak as above (TEXT + // affinity stored '1'); a boolean column gives NUMERIC affinity and the + // `booleanFields` read-coercion below converts the stored 1/0 back to a + // real JS boolean. case 'boolean': + case 'toggle': col = table.boolean(name); break; case 'date': @@ -1855,22 +1873,6 @@ export class SqlDriver implements IDataDriver { case 'time': col = table.time(name); break; - case 'json': - case 'object': - case 'array': - case 'image': - case 'file': - case 'avatar': - case 'location': - case 'address': - case 'composite': - case 'multiselect': - case 'checkboxes': - case 'tags': - case 'repeater': - case 'vector': - col = table.json(name); - break; case 'lookup': col = table.string(name); if (field.reference_to) { @@ -1887,7 +1889,12 @@ export class SqlDriver implements IDataDriver { case 'formula': return; // Virtual — no column default: - col = table.string(name); + // Array/object-valued types are stored as a JSON column. Driven by the + // single `JSON_COLUMN_TYPES` source so this DDL switch and `isJsonField` + // (the read-side deserializer) can never drift — the drift between them + // is exactly what let array-valued fields reach the binder un-serialized + // (#field-zoo). Everything else is a plain string. + col = JSON_COLUMN_TYPES.has(type) ? table.json(name) : table.string(name); } if (col) { From 5123c5679034d36342678d559a41553e1097c75e Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 14:33:21 +0800 Subject: [PATCH 2/2] chore(changeset): driver-sql type-fidelity patch Co-Authored-By: Claude Opus 4.8 --- .changeset/field-type-fidelity.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/field-type-fidelity.md diff --git a/.changeset/field-type-fidelity.md b/.changeset/field-type-fidelity.md new file mode 100644 index 0000000000..2001258681 --- /dev/null +++ b/.changeset/field-type-fidelity.md @@ -0,0 +1,11 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): round-trip rating/slider/toggle/progress with type fidelity + +`rating`/`slider`/`toggle`/`progress` had no case in the DDL column-type switch, so they fell to `default → table.string` (TEXT affinity). SQLite then coerced the written value to a string — `rating: 4` read back `'4'`, `toggle: true` read back `'1'` — so the value persisted but the JS type leaked on read. On a low-code platform where field types are author-driven, a field that silently returns the wrong type is a runtime-fidelity trap the static gates and value-loss tests don't catch. + +- `rating`/`slider`/`progress` now map to a REAL (numeric) column. +- `toggle` maps to a boolean column and is registered in the boolean read-coercion path, so stored `1`/`0` come back as real JS booleans. +- The object-valued `record`/`video`/`audio` types are folded into the shared `JSON_COLUMN_TYPES` source, and the DDL `default` case now derives JSON-vs-string from that set, so the column-type switch and `isJsonField` (the read-side deserializer) can no longer drift.