Skip to content

fix(driver-turso): the remote face emits the declared UNIQUE, and an unbacked conflictKeys upsert refuses in an envelope (#8413) - #8447

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8413-turso-remote-unique-ddl
Aug 13, 2026
Merged

fix(driver-turso): the remote face emits the declared UNIQUE, and an unbacked conflictKeys upsert refuses in an envelope (#8413)#8447
os-zhuang merged 2 commits into
mainfrom
claude/issue-8413-turso-remote-unique-ddl

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8413

RemoteTransport's DDL builder had no notion of unique at all — grep -ciE 'unique' over the whole file returned zero, re-verified on origin/main before implementing — so a column declared { type: 'string', unique: true } reached a remote Turso endpoint as a bare "email" TEXT. Both consequences triage ruled onto this card are fixed here.

1. The remote DDL now emits the declared UNIQUE

As a companion CREATE UNIQUE INDEX, not an inline column constraint. The PM flagged the equivalence of the two as an assumption to measure; it does not hold, and both legs of the measurement point the same way:

  • Retrofit. SQLite has no ALTER TABLE ... ADD CONSTRAINT, so an inline UNIQUE reaches an already-created table only through a full create-copy-drop-rename rebuild. A CREATE UNIQUE INDEX is one statement that touches no row. Since the tables this defect has been filling with duplicates all already exist, the inline form would have been unreachable exactly where it is needed.
  • Parity.SqlDriver already materializes field-level unique as a UNIQUE INDEX (syncDeclaredIndexes), never inline.

The index is built through uniqueIndexesFromFields — driver-sql's shared helper, whose own contract is that it is "the ONLY place field-level uniqueness becomes an index, so the create-table, alter-table, SQLite-rebuild and drift-detection paths cannot disagree". The remote face is now the fourth such path instead of a second definition, so both faces converge on the same index name and the same key, including the ADR-0120 D1/D3 per-organization NULL-safe form.

That last part is why the driver hands the transport a tenant-field resolver rather than letting it guess: remote DDL runs beforeregisterRemoteFieldMetadata fills tenantFieldByTable, so a lookup by table name would read empty at exactly the moment the index is built and would emit a platform-wide unique where the local face builds a per-organization one — rejecting two organizations that legitimately hold the same value. That would have been a fresh divergence rather than the one being fixed.

2. An unbacked conflictKeys upsert refuses in an ADR-0112 envelope

VALIDATION_ERROR / 400, naming the object, the keys and the remedy, with the SQLite text preserved as cause. Previously a raw SqliteError (code: 'SQLITE_ERROR', status: undefined).

The PM's second assumption — that the remote face may not be able to detect a missing backing index without an extra round trip — is refuted: it needs no detection at all. SQLite already answers the question exactly when it matters, for free, by refusing the statement. The fix reads that answer and classifies it. No probe, no extra round trip, and no false refusals — a table carrying a unique index this driver never created still works, which a remembered-what-we-created registry would have broken.

The hard guard: this is not a data migration

No stored row is deleted, merged or rewritten, and index creation is never forced or retried over existing data.

  • New tables get their indexes in the same batch as their CREATE TABLE — empty by construction, so they cannot fail on data, and this costs zero extra round trips.
  • Existing tables are retrofitted outside that batch. On a libsql write batch one statement's failure rolls back every other statement in it, so a single table holding duplicates would otherwise have silently undone the schema sync of every other object in the boot.
  • A duplicate-bearing table is reported at error level naming the table and the remedy, and the boot continues. error is the level AGENTS.md's degradation rule requires: writes keep succeeding and reads keep returning rows, so nothing looks wrong from the outside while a declared constraint is not enforced. That is also why it is a separate sink from the existing warn diagnostic one.

Round-trip cost, stated rather than hidden: one extra batch per sync that touches an existing table declaring a unique field. The per-statement fallback runs only after a batch has already failed, so the cost of precision is paid by the deployment that needs the diagnosis, not by every boot.

Verification

conflictKeys had zero coverage in this package, so the fix starts by building its own net: turso-local-remote-unique-parity.test.ts, 8 pins over a real SQLite wearing the libsql interface.

The load-bearing one is the cross-face divergence itself — same declaration, same duplicate write, local vs remote, one assertion — which is what stops the two faces drifting apart again. Beside it: the DDL actually emitted (so a future rewrite cannot quietly drop it), and the enveloped refusal asserted on codeandstatus.

The refusal pin carries a positive control: with the index present, the same conflictKeys upsert merges. Without that half, a transport that refused every conflictKeys upsert unconditionally would pass the refusal assertion — having broken the capability rather than restored it.

Reverse verification, direction predicted before running, both legs isolated:

ablationpredictedmeasured
index emission neutralizedDDL pins, remote half of divergence pin, and the positive control go red; refusal pin stays greenexactly that — 4 red, 4 green. Divergence pin reported { local: true, remote: false }, the card's own signature
envelope neutralized onlyrefusal pin alone goes red with SQLITE_ERROR; all else greenexactly that — 1 red, 7 green

The refusal pin staying green under the first ablation is the point: its table never had a unique index to lose, which is what makes it the pin for the already-created-table case rather than a second copy of the DDL pin.

Local: 977/977 package tests pass, tsc --noEmit clean. Downstream sweep (--filter '...@objectstack/driver-turso', the prefix/consumer direction): all 6 consumers typecheck clean.

Not addressed here

The local face has the same un-enveloped gap — an unbacked conflictKeys upsert on local also throws a raw SqliteError with status: undefined (measured). It is pre-existing, wider than this driver (it is driver-sql, so every dialect), and outside this card's declared file surface, so it is filed as #8445 rather than fixed here. Consequence 2 above is therefore scoped to the remote face, as triage scoped it.


Generated by Claude Code

…ope an unbacked conflictKeys upsert (#8413)
RemoteTransport's DDL builder had no notion of `unique` at all, so a column
declared `unique: true` reached a remote Turso endpoint as a bare TEXT column.
Two consequences of one cause:
1. Declared uniqueness was not enforced on the remote face — the same object
definition and the same duplicate write were rejected locally and accepted
remotely, so a remote deployment accumulated duplicates silently.
2. `conflictKeys` upserts could not work at all: SQLite requires an ON CONFLICT
target to be backed by a PRIMARY KEY or UNIQUE index, so every business-key
upsert raised a raw SqliteError with no ADR-0112 envelope.
The remote face now builds its unique indexes through `uniqueIndexesFromFields`
— the same helper SqlDriver uses locally — so both faces converge on one index
name and one key, including the ADR-0120 D1/D3 per-organization form. A new
table's indexes ride its own CREATE TABLE batch; an existing table's are applied
outside it, so one table's failure cannot roll back another object's DDL.
A unique index that cannot be created over existing duplicates is reported at
`error` level and skipped — no stored row is deleted, merged or rewritten, and
the boot continues. That case is then answered at the point of use by a
VALIDATION_ERROR/400 refusal naming the object, the keys and the remedy, with
the SQLite text preserved as `cause`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 1:23pm

Request Review

@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-turso.

6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-turso)
  • content/docs/deployment/cli.mdx(via @objectstack/driver-turso)
  • content/docs/deployment/environment-variables.mdx(via @objectstack/driver-turso)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/driver-turso)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-turso)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-turso)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 13:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 1d1ca8eAug 13, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8413-turso-remote-unique-ddl branch August 13, 2026 14:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude