Uh oh!
There was an error while loading. Please reload this page.
fix(driver-turso): the remote face emits the declared UNIQUE, and an unbacked conflictKeys upsert refuses in an envelope (#8413) - #8447
Merged
Conversation
…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
…so-remote-unique-ddl
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 13, 2026 13:43
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#8413
RemoteTransport's DDL builder had no notion ofuniqueat all —grep -ciE 'unique'over the whole file returned zero, re-verified onorigin/mainbefore 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:ALTER TABLE ... ADD CONSTRAINT, so an inlineUNIQUEreaches an already-created table only through a full create-copy-drop-rename rebuild. ACREATE UNIQUE INDEXis 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.SqlDriveralready materializes field-leveluniqueas 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 before
registerRemoteFieldMetadatafillstenantFieldByTable, 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
conflictKeysupsert refuses in an ADR-0112 envelopeVALIDATION_ERROR/400, naming the object, the keys and the remedy, with the SQLite text preserved ascause. Previously a rawSqliteError(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.
CREATE TABLE— empty by construction, so they cannot fail on data, and this costs zero extra round trips.writebatch 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.errorlevel naming the table and the remedy, and the boot continues.erroris 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 existingwarndiagnostic 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
conflictKeyshad 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
conflictKeysupsert merges. Without that half, a transport that refused everyconflictKeysupsert unconditionally would pass the refusal assertion — having broken the capability rather than restored it.Reverse verification, direction predicted before running, both legs isolated:
{ local: true, remote: false }, the card's own signatureSQLITE_ERROR; all else greenThe 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/977package tests pass,tsc --noEmitclean. 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
conflictKeysupsert on local also throws a rawSqliteErrorwithstatus: undefined(measured). It is pre-existing, wider than this driver (it isdriver-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