Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .changeset/turso-json-column-type-asymmetry-declared.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
'@objectstack/driver-turso': patch
---

chore(driver-turso): declare and pin the `Field.json` column-type asymmetry between the local and remote transports (#12586)

`TursoDriver` is dual-transport, and one declared `Field.json` becomes a
different physical column on each. Local/replica mode extends `SqlDriver` and
lets knex spell it (`table.json(name)` — a `json` column); remote mode never
touches knex and spells its own SQLite types in
`RemoteTransport.mapFieldTypeToSQL` (`TEXT`). Nothing in the tree said whether
that was a design or an oversight — a grep found the two `mapFieldTypeToSQL`
lines and one passing comment — and no test would have gone red if either side
moved.

⛔ Nothing is broken and no behaviour changes here. Both transports round-trip
every `VALUE_ROUNDTRIP_CASES` value faithfully today and did before this PR.

**Why it is still worth recording.** Every column type in this driver is
spelled differently by the two halves — `varchar(255)`/`TEXT`,
`float`/`REAL`, `boolean`/`INTEGER` — and for all of those the difference is
cosmetic, because SQLite derives affinity from substrings of the declared type
name and both spellings land in the same class. `json` is the one that does
not: it matches none of SQLite's affinity markers, so it carries **NUMERIC**
affinity and converts number-like input on the way in, while `TEXT` converts
nothing. Measured on the shared fixture, that is not theoretical — a declared
`Field.json` holding the native `123` is an **INTEGER cell locally and a TEXT
cell remotely**, with `find()` answering `123` on both. Equal answers, unequal
bytes: the #11535 class in its quiet phase, where the next codec change has no
reason to be kind to both. PR #12585's ablation is the same fact in its loud
phase — the pre-#12380 `json` branch broke the two transports by *different*
counts, diverging on `s_0123`, because only the local column had NUMERIC
affinity to destroy a bare `'0123'` with.

**What lands:**

- The declaration, at the site a reader lands on when they ask why this returns
`TEXT` — `RemoteTransport.mapFieldTypeToSQL`'s doc comment: the full
local/remote type table, which rows are cosmetic and which one is not, the
affinity mechanism as the "why it is safe today", and the instruction to
delete or invert the pin rather than patch it green.
- The pin, `turso-json-column-type-asymmetry.test.ts`, driven by the same
`VALUE_ROUNDTRIP_FIELDS` / `VALUE_ROUNDTRIP_CASES` table the round-trip
conformance suite uses. It asserts each transport's declared types from the
**catalog**, demonstrates the affinity mechanism with raw SQL that bypasses
the driver codec, and asserts that the set of cases whose on-disk storage
class differs is exactly `{n_int, n_real}` — so convergence (an empty set) is
as red as one side drifting (a longer one).
- A note in `turso-value-roundtrip-conformance.test.ts` saying it is
deliberately blind to this, since it is green either way.

⛔ Convergence (making both transports emit one type) is **not** done here.
It changes what new columns are physically declared as and needs the
un-measured "why did remote choose `TEXT`?" answered first; #12586 ruled it a
separate decision.

Grade: `patch`, argued rather than defaulted. Not `minor` — no new public API,
no widened accept set, no behaviour change, and the emitted DDL is byte-for-byte
what it was. Not `skip-changeset` either, though the only executable code this
PR ships is a test: what becomes a checked invariant here is a property of the
published package (which physical column a declared field gets on each
transport), and the CHANGELOG line is the record a future reader needs when
this guard goes red on them.
62 changes: 62 additions & 0 deletions packages/drivers/driver-turso/src/remote-transport.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1996,6 +1996,68 @@ export class RemoteTransport {

/**
* Map ObjectStack field types to SQLite column types for DDL.
*
* ## [#12586] These are NOT the same type names the local transport emits,
* and that is a recorded decision — not an oversight
*
* `TursoDriver` is dual-transport. Local/replica mode extends `SqlDriver`
* and lets knex spell the column (`table.json(name)`, `table.string(name)`,
* …); remote mode never touches knex and spells its own types here. The two
* disagree on **every** column:
*
* ```text
* declared field local (knex) remote (this method)
* ------------------- -------------- --------------------
* { type: 'string' } varchar(255) TEXT
* { type: 'number' } float REAL
* { type: 'boolean' } boolean INTEGER
* { type: 'json' } json TEXT
* { multiple: true } json TEXT
* ```
*
* For every row but the last two the disagreement is **cosmetic**. SQLite
* derives a column's affinity from substrings of the declared type name, so
* `varchar(255)` and `TEXT` are both TEXT affinity, `float` and `REAL` are
* both REAL affinity, and `boolean` and `INTEGER` both store 0/1 as an
* integer. Measured, not derived: writing the same value through each
* transport lands it in the same storage class on all of them.
*
* ⚠️ **The JSON routes are the exception, and they are the reason this
* comment exists.** `json` contains none of SQLite's affinity markers
* (`INT`, `CHAR`/`CLOB`/`TEXT`, `BLOB`, `REAL`/`FLOA`/`DOUB`), so it takes
* **NUMERIC** affinity and converts number-like input on the way in; the
* `TEXT` returned below takes TEXT affinity and converts nothing. So the two
* transports do not merely spell this column differently — they disagree
* about **what a value becomes on disk**.
*
* ### Why it is safe today
*
* Both transports round-trip every `VALUE_ROUNDTRIP_CASES` value faithfully
* (`turso-value-roundtrip-conformance.test.ts`, both halves). They arrive
* there by different routes: #12380 made the local `Field.json` codec
* injective, so the NUMERIC-affinity column is only ever handed an encoded
* form it has nothing to convert; this transport's own `serializeValue` /
* `mapRows` reach the same answer over a column where no conversion was
* available to begin with.
*
* ⛔ Safe is not the same as identical. Measured on the shared fixture: a
* declared `Field.json` holding the native `123` is an INTEGER cell locally
* and a TEXT cell here, and `find()` answers `123` on both. That is the
* #11535 class in its quiet phase — two paths agreeing on every visible
* answer while standing on different ground. PR #12585's ablation is the
* loud phase: restoring the pre-#12380 SQLite `json` branch broke the two
* transports by DIFFERENT counts, diverging on `s_0123`, because only the
* local column had NUMERIC affinity to destroy a bare `'0123'` with.
*
* ### ⛔ Before you converge them
*
* Making both sides emit one type changes what new columns are physically
* declared as, and it needs the un-measured *"why did remote choose TEXT?"*
* answered first — so #12586 ruled it out of scope and recorded the
* asymmetry instead. `turso-json-column-type-asymmetry.test.ts` pins the
* pair and goes red if either side moves. When convergence is genuinely
* taken on, **delete or invert that pin** as part of the change; ⛔ never
* edit its expectations to match new output.
*/
private mapFieldTypeToSQL(field: any): string {
if (field.multiple) return 'TEXT'; // JSON array stored as text
Expand Down
Loading
Loading