|
| 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