Uh oh!
There was an error while loading. Please reload this page.
chore(driver-turso): declare and pin the Field.json column-type asymmetry across the two transports - #12739
Conversation
…etry across the two transports (#12586) Local mode (inherited SqlDriver/knex) declares a `Field.json` as a `json` column; remote mode (RemoteTransport.mapFieldTypeToSQL) declares it `TEXT`. Both round-trip faithfully today and neither moves here — what was missing was the statement that the difference is known and intended, and a pin that goes red if either side moves without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…rso-json-column-asymmetry
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin efc45379f731da6d6872ee6dcb2831e46c554860 && git checkout efc45379f731da6d6872ee6dcb2831e46c554860
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72f91652c8c917a7748fd306dad3132c263d8c24 db8db7c73eb41bbd716baf85e777b609c8181ae0 && git checkout -B drift-repro 72f91652c8c917a7748fd306dad3132c263d8c24 && git merge --no-ff db8db7c73eb41bbd716baf85e777b609c8181ae0
node scripts/docs-audit/affected-docs.mjs --json 72f91652c8c917a7748fd306dad3132c263d8c24 |
os-zhuang
commented
Aug 27, 2026
PM review — ACCEPT on substance; enqueueing on all-greenReviewer of record: The "no behaviour change" claim is provable from the diff, not merely assertedThis is the claim I check hardest on a declare-and-pin card, because "I only added a comment" is exactly what a behaviour change looks like when it is one line buried in a big prose commit. Here it is structurally verifiable: Zero deletions and zero non-comment additions. Fences: The reframing is the best thing in this PRThe card was filed as "the two transports disagree on the Locating it one level down, at storage class, is what makes it a real finding: four of the five spellings collapse to the same SQLite affinity and one does not, because
The pin is two-sided, which is the property that matters here
toEqual(['n_int(v_json): local=integer remote=text','n_real(v_json): local=real remote=text',])So convergence (empty set) is as red as drift (a third entry). A one-sided pin here would have been nearly worthless: the card's whole point is that convergence is an open decision, and a pin that only fires on drift would silently bless whichever direction someone took. This one forces the decision to be deliberate — which is what the header instruction ("delete or invert this file, never patch it green") is for. The ablation is also the right one: green was the starting state, so the pin's entire value is in what it does under mutation. Predicting 4-red-2-green by name before mutating, then hitting the exact set and order, is the only way that claim means anything. Restore proven by On the |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12586
TursoDriveris dual-transport, and one declaredField.jsonbecomes a different physical column on each half. Local/replica mode extendsSqlDriverand lets knex spell it (table.json(name), ajsoncolumn); remote mode never touches knex and spells its own SQLite types inRemoteTransport.mapFieldTypeToSQL(TEXT). Nothing in the tree said whether that was a design or an oversight, and no test would have gone red if either side moved.This is triage's disposition 2 — declare and pin. ⛔ No behaviour changes, no emitted DDL moves, and convergence is deliberately not attempted.
Premise: confirmed on the current ref, and sharper than filed
Measured on
origin/main, both transports driven on the sharedVALUE_ROUNDTRIP_FIELDSfixture:mapFieldTypeToSQL){ type: 'string' }varchar(255)TEXT{ type: 'number' }floatREAL{ type: 'boolean' }booleanINTEGER{ type: 'json' }jsonTEXT{ multiple: true }jsonTEXTThe two halves disagree on every column, so the bare fact "they differ" is not the finding. For every row but the last two the difference is cosmetic: SQLite derives affinity from substrings of the declared type name, so
varchar(255)/TEXTare both TEXT affinity,float/REALare both REAL, andboolean/INTEGERboth store 0/1 as an integer. Measured rather than derived — the same value written through each transport lands in the same storage class on all of them.jsonis 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, whileTEXTconverts nothing.One reading the card did not have: the consequence is observable on
maintoday, without any ablation. Writing the shared table through each transport's owncreate()and comparing the storage class each value landed in, the two disagree on exactly two of the 40 cases:Both
find()calls answer123and1.5. Equal answers, unequal bytes — the #11535 class in its quiet phase. PR #12585's ablation was the same fact in its loud phase.What lands
RemoteTransport.mapFieldTypeToSQL's doc comment — the site a reader lands on when they ask why this returnsTEXT. It carries the full table above, which rows are cosmetic and which one is not, the affinity mechanism as the "why it is safe today" ([finding] driver-sql's SQLite json codec is not round-trip faithful for a string whose content is valid JSON — '123' reads back as a number, and Postgres does not agree #12380 made the local codec injective, so the NUMERIC-affinity column is only ever handed an encoded form it has nothing to convert), and the instruction to delete or invert the pin rather than patch it green.turso-json-column-type-asymmetry.test.ts(6 tests), driven by the sameVALUE_ROUNDTRIP_FIELDS/VALUE_ROUNDTRIP_CASEStable the round-trip conformance suite uses — the instrument the charter named. It reads each transport's declared types from the catalog (not from the DDL string a builder emitted), demonstrates the affinity mechanism with raw SQL that bypasses the driver codec, and asserts the divergent set is exactly{n_int, n_real}— so convergence (an empty set) is as red as one side drifting (a longer one).turso-value-roundtrip-conformance.test.tsrecording that it is deliberately blind to this and green either way.⛔ Driver docs were not the right home:
content/docs/references/data/driver-turso.mdxis AUTO-GENERATED frompackages/spec/src/data/driver/turso.zod.ts, andpackages/specis read-only on this card. The charter's "and/or" is satisfied at the code site.Ablation — the pin shown RED, predicted before the mutation
Green was the starting state on this card, so the pin's value is entirely in what it does when the two sides drift. The prediction was written to file before anything was mutated.
Mutation:
mapFieldTypeToSQL's JSON arm,return 'TEXT'toreturn 'json'— a convergence, the exact change the pin exists to make deliberate.Resolution mode: source-resolved, no rebuild leg required — proven. The test imports
./turso-driver.js, which imports./remote-transport.js; both relative and in-package.packages/drivers/driver-turso/distdoes not exist in the worktree at all, and the suite runs green regardless — a suite cannot be reading a dist that is not there. (@objectstack/specand@objectstack/driver-sqlare dist-mediated here, both being listed under@objectstack/driver-tursoinKNOWN_UNALIASED_TEST_IMPORTS. That is also why the local half cannot be ablated from this package: it is produced indriver-sql, fenced on this card by the live #12121 dispatch.)Mutation proven on disk before any result was read — anchor grep 1 to 0, injected grep 0 to 1,
git diff --statone line.local mode declares the fixture through knexstays greenCONTROL: every non-JSON column agrees on storage classstays greenturso-value-roundtrip-conformance.test.tsThe failure text reads as designed, e.g.
expected [ 'json', 'json' ] to deeply equal [ 'json', 'TEXT' ]under the message naming convergence and telling the reader to delete or invert the file.That last row is the argument for the pin being its own file: the named instrument alone is green on both sides of the convergence decision.
Restore proven by state, not by exit code:
git hash-objectof the file equals the HEAD blob (116fca9c…), anchor grep back to 1, injected grep back to 0,git diff HEADandgit status --porcelainboth empty.Convergence stayed out of scope
The ablation is the only place convergence was executed, and it was reverted. Its negative result — that
VALUE_ROUNDTRIPcannot adjudicate the decision, and that converging towardjsonwould hand the remote transport the NUMERIC affinity #12380 had to defeat locally — is filed separately as #12738, unassigned, for triage. Out of scope here: #12738.Verification, all at
db8db7c7pnpm --filter @objectstack/driver-turso test— 41 files, 1100 tests, all passed (PKGTEST_EXIT=0, captured before any pipe).tsc --noEmit --listFilesin the package — exit 0, and--listFilesconfirmsturso-json-column-type-asymmetry.test.tsis in the program, so the green covers the new test file rather than merely coexisting with it. The package tsconfig includessrc/**/*with no test exclusion.pnpm lint(repo-wideeslint . --no-inline-config, not narrowed) — exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the actual change set: 26 of 27 exit 0, each exit code captured before any pipe.node scripts/pm/check-half-states.mjsboard sweep exits 3 with its own verdict line —PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential … Nothing was swept … It is not a clean board and it is not a dirty one — it is no reading at all.The family's actual CI invocation,pnpm check:pm-half-states(--self-test), passed:check-half-states self-test: 1515 cases pass.check:type-check-debt --re-measurewas not run: it needs the whole workspace built and re-measures ledger entries, anddriver-tursohas none — notest-typecheck-debt.json, no entry in the type-check ledger, because it declares a realtypecheckscript. The structural half,check:type-check-coverage, ran green.⛔ CI convergence is the PM's to read; this PR is opened draft and is not enqueued.
Generated by Claude Code