Skip to content

Commit 4045b95

Browse files
os-warrenclaude
andauthored
fix(driver-sql): make the SQLite Field.json codec injective — one encoding across all three dialects (#12401)
`formatInput` now `JSON.stringify`s every `Field.json` value on every dialect and `formatOutput` parses it back, deleting the SQLite branch rather than adding one. Postgres and MySQL are untouched: this makes SQLite match what they already did. Measured live (SQLite / PG 16.13 / MySQL 8.0.46), stored cell read back through a separate raw catalog query: PG and MySQL were 17/17 faithful, SQLite 13/17 type-changed, through three mechanisms — the read-side parse, SQLite's NUMERIC affinity on a `json`-declared column eating number-like strings before storage, and native booleans landing as INTEGER 1/0. The declared contract decides which dialect is right: `json`'s stored contract is `z.unknown()`, an explicitly open contract that admits both `123` and `'123'` as legal values of one field. `backfillCanonicalJsonEncoding` converges existing SQLite rows on `syncSchema`, in the same shape and posture as the datetime/time storage-format backfills beside it. It converts the one unambiguous on-disk class (TEXT that is not valid JSON) and refuses to guess at the two that the pre-fix encoding made ambiguous. Part of #12380 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4e7ae5 commit 4045b95

3 files changed

Lines changed: 755 additions & 19 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
fix(driver-sql): make the SQLite `Field.json` codec injective — one encoding across all three dialects (#12380)
6+
7+
**BREAKING** storage-format change for `Field.json` columns on SQLite (and the
8+
SQLite-backed `driver-turso` / `driver-sqlite-wasm`, which inherit this codec),
9+
shipped as `minor` under the repo's launch-window convention for breaking
10+
changes. Postgres and MySQL are **untouched** — this makes SQLite match what
11+
they have always done.
12+
13+
`formatInput` now `JSON.stringify`s every `Field.json` value on every dialect,
14+
and `formatOutput` parses it back. That **deletes a dialect branch rather than
15+
adding one**.
16+
17+
## What was wrong
18+
19+
Measured 2026-08-26 through the driver boundary on live SQLite, live Postgres
20+
16.13 and live MySQL 8.0.46, with each stored cell read back through a separate
21+
raw catalog query: **Postgres and MySQL were 17/17 faithful; SQLite was 13/17
22+
type-changed.** Three independent mechanisms, only two of them reversible:
23+
24+
1. **Read-side.** `formatOutput` `JSON.parse`s every string in a json column, so
25+
a stored string whose *content* is valid JSON came back type-changed —
26+
`'true'` → boolean, `'null'` → null, `'[]'` → array, `'{"a":1}'` → object.
27+
2. **Write-side.** The column is declared type `json`, which contains none of
28+
`INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/`FLOA`/`DOUB`, so SQLite's affinity
29+
rules fall through to **NUMERIC** and a bound number-like string was converted
30+
to INTEGER/REAL *before storage*: `'123'`, `' 123 '`, `'0123'`, `'1e5'`,
31+
`'1.0'`, `'-0'` were destroyed on disk. ⛔ Not reversible.
32+
3. **Native booleans.** `true` was stored as INTEGER 1 and read back as the
33+
number `1` — `formatOutput`'s `booleanFields` pass is keyed to declared
34+
`Field.boolean` *columns*, not to booleans inside a json payload.
35+
36+
The contract decides which dialect is right, not strictness: `json`'s stored
37+
contract is `z.unknown()` because *"openness is now an explicit decision, not an
38+
accident of nobody checking"* (`packages/spec/src/data/field-value.zod.ts`). An
39+
explicitly-open contract admits both `123` and `'123'` as legal values of one
40+
field, so no driver may collapse them onto one representation.
41+
42+
The live consumer is `sys_setting.value`, which is `Field.json`, and the settings
43+
service persists verbatim and reads back with no re-coercion by declared type —
44+
so the driver's answer is what the caller gets, on the dialect tenant
45+
environments actually run.
46+
47+
## What changes on disk, and what does not
48+
49+
The DDL is unchanged — the column is still declared `json`, so NUMERIC affinity
50+
is still in force. The encoded form defeats it because a string's encoding
51+
carries its quotes (`'123'` → `"123"`, which is not a numeric literal). Pinned
52+
live rather than reasoned.
53+
54+
For **new** writes the on-disk delta is exactly two classes:
55+
56+
- **strings** are now quoted JSON text;
57+
- **booleans** are now TEXT `true`/`false` instead of INTEGER `1`/`0`.
58+
59+
Objects, arrays, `null` and **numbers** are byte-identical to before (`123` bound
60+
as a number and `"123"` bound as text both land as INTEGER `123`).
61+
62+
⚠️ An out-of-band reader of a SQLite file — anything reading the table with its
63+
own SQL rather than through this driver — now sees quoted JSON text where it saw
64+
a bare value.
65+
66+
## The migration, and the limits of what it can recover
67+
68+
`backfillCanonicalJsonEncoding` runs on `syncSchema`/`initObjects` for existing
69+
tables, the same posture and shape as the `backfillCanonicalDatetimes` and
70+
`backfillCanonicalTimes` storage-format migrations beside it: one `UPDATE` per
71+
column, failures logged and swallowed, correctness never contingent on it having
72+
run. It converts the **one on-disk class the pre-fix encoding left unambiguous** —
73+
a TEXT cell that is not valid JSON, which nothing but a stored plain string could
74+
have produced — into its quoted form. Idempotent by construction: the `WHERE` is
75+
the exact complement of the `SET`'s output, so a converted row cannot match again
76+
and re-running costs one scan and zero writes.
77+
78+
⛔ **It does not guess, because the rest cannot be guessed**, and two classes are
79+
therefore left exactly as they are:
80+
81+
- **INTEGER/REAL cells.** A number, a boolean, and a number-like string eaten by
82+
NUMERIC affinity are the *same bytes* on disk — `123` the number and `'123'`
83+
the string are one INTEGER `123`. No migration can know which was written.
84+
- **TEXT cells that already parse.** A stored object `{"a":1}` and a stored
85+
*string* `'{"a":1}'` were byte-identical before this change. Re-quoting them
86+
would turn every legacy object and array into a string — corrupting the common
87+
case to guess at the rare one.
88+
89+
⇒ Those rows read after this change exactly as they read before it. **The class
90+
stops growing; it is not retroactively repaired.** Maintainer ruling 2026-08-26,
91+
with that cost accepted explicitly.
92+
93+
The migration changes **no read**: a legacy plain string reads back as that
94+
string before it runs (via `formatOutput`'s parse fallback, kept for exactly this
95+
reason and now documented as the pre-#12380 read-side repair) and after it runs.
96+
It is a canonicalisation that makes the on-disk format uniform and injective
97+
going forward, not a repair of something that reads wrong today.
98+
99+
## What upgraders may notice
100+
101+
Values that were being **corrupted** now read back correctly. Code that adapted
102+
to the corruption is what changes underneath: a boolean `Field.json` value that
103+
read back as `1` now reads back as `true`, and a string whose content is valid
104+
JSON now reads back as that string instead of the structure it looked like.
105+
Filters are unaffected — every scalar comparison operator on a json column is
106+
already refused by the driver (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), so no
107+
predicate could have been keyed to the old stored text.
108+
109+
<!-- adr-0087: not-required (no-migration-prescription) A storage-codec change inside one driver: no authorable metadata key is removed, renamed or re-shaped, no `packages/spec` declaration changes, and the `json` field type's stored contract (`z.unknown()`) is exactly what it was — so there is no tombstone and nothing whatsoever for `objectstack migrate meta` to rewrite. The migration this change needs is over DATA ROWS, not metadata, and it is performed automatically by the driver at schema sync (`backfillCanonicalJsonEncoding`); there is no step an upgrader is prescribed to take, which is why no ledger entry could carry one. -->

0 commit comments

Comments
 (0)