Skip to content

chore(driver-turso): declare and pin the Field.json column-type asymmetry across the two transports - #12739

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12586-turso-json-column-asymmetry
Aug 27, 2026
Merged

chore(driver-turso): declare and pin the Field.json column-type asymmetry across the two transports#12739
os-zhuang merged 2 commits into
mainfrom
claude/issue-12586-turso-json-column-asymmetry

Conversation

@claude

@claudeclaudeBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#12586

TursoDriver is dual-transport, and one declared Field.json becomes a different physical column on each half. 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, 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 shared VALUE_ROUNDTRIP_FIELDS fixture:

declared fieldlocal (knex)remote (mapFieldTypeToSQL)
{ type: 'string' }varchar(255)TEXT
{ type: 'number' }floatREAL
{ type: 'boolean' }booleanINTEGER
{ type: 'json' }jsonTEXT
{ multiple: true }jsonTEXT

The 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)/TEXT are both TEXT affinity, float/REAL are both REAL, and boolean/INTEGER both 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.

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.

One reading the card did not have: the consequence is observable on main today, without any ablation. Writing the shared table through each transport's own create() and comparing the storage class each value landed in, the two disagree on exactly two of the 40 cases:

n_int(v_json): local=integer remote=text
n_real(v_json): local=real remote=text

Both find() calls answer 123 and 1.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

  • The declaration, on RemoteTransport.mapFieldTypeToSQL's doc comment — the site a reader lands on when they ask why this returns TEXT. 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.
  • The pin, turso-json-column-type-asymmetry.test.ts (6 tests), driven by the same VALUE_ROUNDTRIP_FIELDS / VALUE_ROUNDTRIP_CASES table 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).
  • A note in turso-value-roundtrip-conformance.test.ts recording that it is deliberately blind to this and green either way.

⛔ Driver docs were not the right home: content/docs/references/data/driver-turso.mdx is AUTO-GENERATED from packages/spec/src/data/driver/turso.zod.ts, and packages/spec is 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' to return '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/dist does 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/spec and @objectstack/driver-sqlare dist-mediated here, both being listed under @objectstack/driver-turso in KNOWN_UNALIASED_TEST_IMPORTS. That is also why the local half cannot be ablated from this package: it is produced in driver-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 --stat one line.

predictedobserved
pin file4 failed, 2 passed (6)4 failed, 2 passed (6)
the 4 red, by nameremote-declares · THE ASYMMETRY · THE MECHANISM · THE CONSEQUENCEsame 4, same order
positive control Alocal mode declares the fixture through knex stays greengreen
positive control BCONTROL: every non-JSON column agrees on storage class stays greengreen
turso-value-roundtrip-conformance.test.tsstays fully green (88)88 passed

The 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-object of the file equals the HEAD blob (116fca9c…), anchor grep back to 1, injected grep back to 0, git diff HEAD and git status --porcelain both empty.

Convergence stayed out of scope

The ablation is the only place convergence was executed, and it was reverted. Its negative result — that VALUE_ROUNDTRIP cannot adjudicate the decision, and that converging toward json would 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 db8db7c7

  • pnpm --filter @objectstack/driver-turso test41 files, 1100 tests, all passed (PKGTEST_EXIT=0, captured before any pipe).
  • tsc --noEmit --listFiles in the package — exit 0, and --listFiles confirms turso-json-column-type-asymmetry.test.ts is in the program, so the green covers the new test file rather than merely coexisting with it. The package tsconfig includes src/**/* with no test exclusion.
  • pnpm lint (repo-wide eslint . --no-inline-config, not narrowed) — exit 0.
  • Gate union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the actual change set: 26 of 27 exit 0, each exit code captured before any pipe.
  • The one that is NOT MEASURED, not red: the bare node scripts/pm/check-half-states.mjs board 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-measure was not run: it needs the whole workspace built and re-measures ledger entries, and driver-turso has none — no test-typecheck-debt.json, no entry in the type-check ledger, because it declares a real typecheck script. 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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 72f91652c8c917a7748fd306dad3132c263d8c24packageMentionDocs.

Which tree this was computed on

This run read content/docs from efc45379f731da6d6872ee6dcb2831e46c554860 — the merge of head db8db7c73eb41bbd716baf85e777b609c8181ae0 into base 72f91652c8c917a7748fd306dad3132c263d8c24, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM review — ACCEPT on substance; enqueueing on all-green

Reviewer of record: domain:engine PM seat (#6367). Verified against the tree at head db8db7c7.

The "no behaviour change" claim is provable from the diff, not merely asserted

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

packages/drivers/driver-turso/src/remote-transport.ts +62 −0
→ lines added that are NOT comments: 0

Zero deletions and zero non-comment additions. mapFieldTypeToSQL cannot have moved. That is a stronger form of the claim than any test could give, and it is worth doing this way whenever a card is supposed to be documentation-only.

Fences: packages/spec 0 · content/docs/releases 0 · docs/adr 0 · .claude 0 · packages/drivers/driver-sql 0. That last one matters — it is the fence the live #12121 dispatch needed, and it is why the local half was declared un-ablatable from this package rather than ablated anyway.

The reframing is the best thing in this PR

The card was filed as "the two transports disagree on the json column." Measuring the whole fixture showed they disagree on every column — varchar(255)/TEXT, float/REAL, boolean/INTEGER, json/TEXT. Taken at the level of spelling, the finding is vacuous: a pin asserting "the two halves differ" would pass on all five rows and mean nothing.

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 json matches no affinity marker and lands on NUMERIC. That is a genuine reframing of the card by the seat implementing it, not a restatement of it.

CONTROL: every non-JSON column agrees on storage class across the transports, though none agrees on spelling (:257) is the assertion that carries the reframing. Without it this suite would be measuring the trivially-true fact and nobody would notice.

The pin is two-sided, which is the property that matters here

:251 asserts exact array equality on the divergent set:

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 git hash-object against the HEAD blob rather than by an exit code.

On the dist argument

"packages/drivers/driver-turso/dist does not exist in the worktree at all, and the suite runs green regardless — a suite cannot be reading a dist that is not there." That is the correct shape of a resolution-mode proof: a claim about absence, discharged by the absence being observable. It also correctly does not overreach — @objectstack/spec and @objectstack/driver-sql are named as dist-mediated, with the consequence (the local half is not ablatable from here) stated rather than glossed.

check:type-check-debt skipped, and correctly explained

Not run because driver-turso has no ledger entry — it declares a real typecheck script, so there is nothing for --re-measure to re-measure. Recorded with the reason rather than as a green. The check-half-states exit 3 is likewise NOT MEASURED, with the family's actual CI invocation (--self-test, 1515 cases) reported separately as the thing that did run.

Outstanding

CI is mid-flight — the completed checks are green and nothing is red. The enqueue bar on this seat is every check green, not the required subset, so this waits. Substance is accepted now so the landing is not gated on my re-reading.

#12738 — the convergence card — is labelled domain:engine and left unassigned for triage to grade. Filing it rather than riding it along was right: its content is the negative result (VALUE_ROUNDTRIP is green on both sides of the decision, so whoever takes convergence on must bring a different instrument), and that result would have been lost inside this PR.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 15:55
@os-zhuang
os-zhuang enabled auto-merge August 27, 2026 15:55
@os-zhuang
os-zhuang added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 242eb0aAug 27, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12586-turso-json-column-asymmetry branch August 27, 2026 16:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude