Skip to content

Commit 1d352d3

Browse files
os-zhuangclaude
andauthored
fix(driver-sql): round-trip rating/slider/toggle with type fidelity (#2025)
* fix(driver-sql): round-trip rating/slider/toggle with type fidelity 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 <noreply@anthropic.com> * chore(changeset): driver-sql type-fidelity patch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4d3e3d5 commit 1d352d3

4 files changed

Lines changed: 173 additions & 25 deletions

File tree

.changeset/field-type-fidelity.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): round-trip rating/slider/toggle/progress with type fidelity
6+
7+
`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.
8+
9+
- `rating`/`slider`/`progress` now map to a REAL (numeric) column.
10+
- `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.
11+
- 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.

packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ const MATRIX: FieldCase[] = [
4747
{ field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } },
4848
{ field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } },
4949
{ field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } },
50-
{ field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 }, xfail: true },
51-
{ field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 }, xfail: true },
50+
{ field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } },
51+
{ field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } },
5252
// temporal — f_time is a #2004 fix (time-of-day)
5353
{ field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } },
5454
{ field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } },
5555
// logic
5656
{ field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } },
57-
{ field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true }, xfail: true },
57+
{ field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } },
5858
// scalar selection
5959
{ field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } },
6060
{ field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } },
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Scalar field types must round-trip with TYPE fidelity, not just value
5+
* survival. Regression (#field-zoo): `rating`/`slider`/`toggle`/`progress`
6+
* were absent from the DDL column-type switch, so they fell to
7+
* `default → table.string` and got TEXT affinity — SQLite then coerced the
8+
* written number/boolean to a string ('4' not 4, '1' not true). The value
9+
* persisted, so value-loss tests stayed green; only the JS type leaked, which
10+
* is a runtime-fidelity trap on a low-code platform where an AI authors
11+
* arbitrary field types.
12+
*
13+
* The fix maps `rating`/`slider`/`progress` to a REAL (numeric) column and
14+
* `toggle` to a boolean column + the `booleanFields` read-coercion registry,
15+
* and folds the object-valued `record`/`video`/`audio` into the shared
16+
* `JSON_COLUMN_TYPES` source so they store/parse as JSON like `composite`.
17+
*
18+
* `number`/`currency`/`percent`/`boolean` are included as the working control:
19+
* the SAME harness must keep returning them with correct types.
20+
*/
21+
22+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23+
import { SqlDriver } from '../src/index.js';
24+
25+
describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => {
26+
let driver: SqlDriver;
27+
28+
beforeEach(async () => {
29+
driver = new SqlDriver({
30+
client: 'better-sqlite3',
31+
connection: { filename: ':memory:' },
32+
useNullAsDefault: true,
33+
});
34+
await driver.initObjects([
35+
{
36+
name: 'zoo',
37+
fields: {
38+
name: { type: 'string' },
39+
// control — these already round-tripped with correct types
40+
f_number: { type: 'number' },
41+
f_currency: { type: 'currency' },
42+
f_percent: { type: 'percent' },
43+
f_boolean: { type: 'boolean' },
44+
// the leak: numeric scalars that used to land as TEXT
45+
f_rating: { type: 'rating', max: 5 },
46+
f_slider: { type: 'slider', min: 0, max: 100, step: 5 },
47+
f_progress: { type: 'progress', min: 0, max: 100 },
48+
// the leak: a switch-rendered boolean
49+
f_toggle: { type: 'toggle' },
50+
// object-valued types folded into JSON_COLUMN_TYPES
51+
f_record: { type: 'record' },
52+
f_video: { type: 'video' },
53+
f_audio: { type: 'audio' },
54+
},
55+
},
56+
]);
57+
});
58+
59+
afterEach(async () => {
60+
await driver.disconnect();
61+
});
62+
63+
it('round-trips numeric scalars as numbers, not strings', async () => {
64+
await driver.create(
65+
'zoo',
66+
{
67+
id: 'z1',
68+
name: 'Specimen',
69+
f_number: 42,
70+
f_currency: 1234.56,
71+
f_percent: 75,
72+
f_rating: 4,
73+
f_slider: 25,
74+
f_progress: 60,
75+
},
76+
{ bypassTenantAudit: true },
77+
);
78+
const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true });
79+
80+
// control
81+
expect(row.f_number).toBe(42);
82+
expect(row.f_currency).toBeCloseTo(1234.56, 5);
83+
expect(row.f_percent).toBe(75);
84+
85+
// the fix: these used to come back as '4' / '25' / '60'
86+
expect(typeof row.f_rating).toBe('number');
87+
expect(row.f_rating).toBe(4);
88+
expect(typeof row.f_slider).toBe('number');
89+
expect(row.f_slider).toBe(25);
90+
expect(typeof row.f_progress).toBe('number');
91+
expect(row.f_progress).toBe(60);
92+
});
93+
94+
it('round-trips toggle as a boolean, not 1/0 or a string', async () => {
95+
await driver.create('zoo', { id: 'z2', name: 'B', f_boolean: true, f_toggle: true }, { bypassTenantAudit: true });
96+
await driver.create('zoo', { id: 'z3', name: 'C', f_boolean: false, f_toggle: false }, { bypassTenantAudit: true });
97+
98+
const on = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true });
99+
const off = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true });
100+
101+
// control
102+
expect(on.f_boolean).toBe(true);
103+
expect(off.f_boolean).toBe(false);
104+
105+
// the fix: toggle used to come back as '1' / '0'
106+
expect(typeof on.f_toggle).toBe('boolean');
107+
expect(on.f_toggle).toBe(true);
108+
expect(typeof off.f_toggle).toBe('boolean');
109+
expect(off.f_toggle).toBe(false);
110+
});
111+
112+
it('round-trips object-valued record/video/audio as objects, not strings', async () => {
113+
await driver.create(
114+
'zoo',
115+
{
116+
id: 'z4',
117+
name: 'D',
118+
f_record: { home: '+1', work: '+2' },
119+
f_video: { url: 'https://cdn/v.mp4', duration: 12 },
120+
f_audio: { url: 'https://cdn/a.mp3', duration: 30 },
121+
},
122+
{ bypassTenantAudit: true },
123+
);
124+
const row = await driver.findOne('zoo', 'z4', { bypassTenantAudit: true });
125+
126+
expect(row.f_record).toEqual({ home: '+1', work: '+2' });
127+
expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 });
128+
expect(row.f_audio).toEqual({ url: 'https://cdn/a.mp3', duration: 30 });
129+
});
130+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ const GLOBAL_TENANT = '__global__';
4141
* the drift between them is exactly what let array-valued fields (multiselect/
4242
* checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and
4343
* crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and
44-
* null" (#field-zoo). `image`/`file`/`avatar` hold structured upload metadata;
45-
* `composite`/`address`/`location` are objects; the rest are arrays.
44+
* null" (#field-zoo). `image`/`file`/`avatar`/`video`/`audio` hold structured
45+
* upload metadata; `composite`/`address`/`location`/`record` are objects; the
46+
* rest are arrays.
4647
*/
4748
const JSON_COLUMN_TYPES = new Set<string>([
48-
'json', 'object', 'array',
49-
'image', 'file', 'avatar',
49+
'json', 'object', 'array', 'record',
50+
'image', 'file', 'avatar', 'video', 'audio',
5051
'location', 'address', 'composite',
5152
'multiselect', 'checkboxes', 'tags', 'repeater', 'vector',
5253
]);
@@ -1115,7 +1116,10 @@ export class SqlDriver implements IDataDriver {
11151116
if (this.isJsonField(type, field)) {
11161117
jsonCols.push(name);
11171118
}
1118-
if (type === 'boolean') {
1119+
// `toggle` shares boolean storage/affinity, so it needs the same
1120+
// read coercion (stored 1/0 → JS true/false) or it leaks back as a
1121+
// number/string instead of a boolean (#field-zoo).
1122+
if (type === 'boolean' || type === 'toggle') {
11191123
booleanCols.push(name);
11201124
}
11211125
if (type === 'date') {
@@ -1841,9 +1845,23 @@ export class SqlDriver implements IDataDriver {
18411845
case 'number':
18421846
case 'currency':
18431847
case 'percent':
1848+
// `rating`/`slider`/`progress` are authored as numeric scalars (a star
1849+
// count, a slider position, a percent-of-completion). Without an explicit
1850+
// case they fell to `default → table.string`, giving the column TEXT
1851+
// affinity so SQLite coerced the written number to a string ('4' not 4) —
1852+
// a silent type-fidelity leak the value-loss tests didn't catch. REAL
1853+
// affinity round-trips them as JS numbers (#field-zoo).
1854+
case 'rating':
1855+
case 'slider':
1856+
case 'progress':
18441857
col = table.float(name);
18451858
break;
1859+
// `toggle` is a boolean rendered as a switch. Same leak as above (TEXT
1860+
// affinity stored '1'); a boolean column gives NUMERIC affinity and the
1861+
// `booleanFields` read-coercion below converts the stored 1/0 back to a
1862+
// real JS boolean.
18461863
case 'boolean':
1864+
case 'toggle':
18471865
col = table.boolean(name);
18481866
break;
18491867
case 'date':
@@ -1855,22 +1873,6 @@ export class SqlDriver implements IDataDriver {
18551873
case 'time':
18561874
col = table.time(name);
18571875
break;
1858-
case 'json':
1859-
case 'object':
1860-
case 'array':
1861-
case 'image':
1862-
case 'file':
1863-
case 'avatar':
1864-
case 'location':
1865-
case 'address':
1866-
case 'composite':
1867-
case 'multiselect':
1868-
case 'checkboxes':
1869-
case 'tags':
1870-
case 'repeater':
1871-
case 'vector':
1872-
col = table.json(name);
1873-
break;
18741876
case 'lookup':
18751877
col = table.string(name);
18761878
if (field.reference_to) {
@@ -1887,7 +1889,12 @@ export class SqlDriver implements IDataDriver {
18871889
case 'formula':
18881890
return; // Virtual — no column
18891891
default:
1890-
col = table.string(name);
1892+
// Array/object-valued types are stored as a JSON column. Driven by the
1893+
// single `JSON_COLUMN_TYPES` source so this DDL switch and `isJsonField`
1894+
// (the read-side deserializer) can never drift — the drift between them
1895+
// is exactly what let array-valued fields reach the binder un-serialized
1896+
// (#field-zoo). Everything else is a plain string.
1897+
col = JSON_COLUMN_TYPES.has(type) ? table.json(name) : table.string(name);
18911898
}
18921899

18931900
if (col) {

0 commit comments

Comments
 (0)