Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): envelope the unbacked-conflict-target upsert refusal - #8571
Conversation
…#8445) `SqlDriver.upsert` let SQLite's error escape as raised when `conflictKeys` named columns with no backing PRIMARY KEY or UNIQUE index: `code: 'SQLITE_ERROR'`, `status: undefined`, and a message that is the statement with its bound values. `mapDataError` reads `code`/`status` to build the response envelope, so with neither set it fell through to its default branch and shipped that SQL text to the caller as the whole body. The condition is now recognised at the throw site and re-raised as `VALIDATION_ERROR` / 400 with the original error kept as `cause`. The wording is `driver-turso`'s remote refusal (#8413), first sentence for first sentence (#5240 — one condition, one wording). Recognition matches SQLite's own sentence, the only channel SQLite fills for this condition. Postgres/MySQL wording is unmeasured and deliberately not guessed at; generalising is tracked on #8567. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8445
The local twin of #8413's remote-face refusal.
SqlDriver.upsertlet SQLite's error escape exactly as raised whenconflictKeysnamed columns with no backing PRIMARY KEY or UNIQUE index.Premise verified before implementing
Measured on this branch's base (
origin/main@719a21bf), local face, knex + better-sqlite3:The card's premise holds exactly as filed. One detail it did not record, and the fix depends on it: knex builds the message as statement, then
-, then SQLite's own sentence — so SQLite's text, the only channel that can discriminate this condition, survives to this face and the remote twin's recognition applies here unchanged.What changed
packages/drivers/driver-sql/src/sql-driver.tsonly:isUnbackedConflictTargetError— a narrow match on SQLite's sentence. Notcode: SQLite raises a plainSQLITE_ERROR, the same generic code a syntax error carries, so a code-based test would swallow every other statement failure. Deliberately notisUniqueViolationError, which answers the opposite condition (an index exists and the row violated it).refuseUnbackedConflictTarget—VALIDATION_ERROR/ 400, original error kept ascause.upsert's catch, placed before the autonumber retry branch: the condition is never an autonumber collision (collidingAutoNumberReservationsasksisUniqueViolationError, false here, so the probe would query the sequences table for nothing and rethrow raw), it is never transient, and it is the only placement that envelopes both exits — a recognition after the branch still escapes raw whenevermayRetryis false.The payload was the larger half.
mapDataErrorbuilds the envelope fromcode/status; with neither set it falls through and serves the thrown message as the whole body — the statement, bound values inlined. So the pins assert the leak as well as the envelope.The judgment call triage delegated: narrow, by measurement
SQLite-recognition-first at the
driver-sqlcall site, not a shared dialect-spanning predicate in@objectstack/types. Ground: this container has no Postgres or MySQL server, so their wording for this condition could not be measured, and transcribing it from memory is the guesswork the dispatch ruled out. MySQL is not even obviously the same condition — knex compilesonConflict().merge()there toON DUPLICATE KEY UPDATE, which takes no conflict target. Postgres and MySQL therefore keep exactly the behaviour they have today. Measuring them, and deciding whether the predicate then belongs besideisUniqueViolationError, is tracked on #8567 (unassigned).Wording
#8413's remote refusal, verbatim, first sentence for first sentence (#5240 — one condition, one wording).
TursoDriverpicks its face fromurl, so a second wording would make the answer a property of the connection string. The remote text is right for this face as written, including its "table created before itsuniquedeclaration was emitted as DDL" clause: this face does create declared indexes, but it cannot create one over a table already holding duplicates, so the same table arrives here with the same missing index.The wording pin here is one-way by construction — it catches a reword of this face, not of the remote one. The two-way runtime comparison needs a package that can import both faces, which is
driver-turso; that is outside this card's declared file surface, so it is filed as #8568 (finding) rather than added here.Tests
New:
sql-driver-upsert-conflict-target-envelope.test.ts— 6 pins. Envelope (codeandstatus, never a baretoThrow()— the un-fixed driver threw for this input too); leak (statement and bound values absent from the caller-visible message, still reachable viacause); wording; and three controls that must not have moved — the sameconflictKeysupsert merges when a declaredunique: truebacks it, the defaultidmerge key still works, and an unrelated statement failure is not swallowed as this refusal.Reverse verification, direction predicted before running: with the call-site line removed, the envelope pin goes red on its first assertion (not through an "it resolved" branch — this input was always refused), the leak and wording pins red with it, and the three controls stay green. Measured:
3 failed | 3 passed, first failureexpected 'SQLITE_ERROR' to be 'VALIDATION_ERROR'. Restored withgit checkoutfrom the commit.Scope
No change to what
upsertaccepts or rejects — the same calls fail, they fail legibly. Hencepatch, notminor: #8413'sdriver-tursohalf wasminorbecause it started refusing writes accepted before, which this does not. Untouched: #8441's batch-verbcodelimb, andschema-drift.ts(#8557).Generated by Claude Code