Uh oh!
There was an error while loading. Please reload this page.
fix(drivers): recognise the unbacked conflict target on Postgres too, from a measured PG 16.13 (#8567) - #8591
Conversation
…8567) Measured PostgreSQL 16.13 through the same knex + pg path SqlDriver.upsert uses: SQLSTATE 42P10, routine=infer_arbiter_indexes, and the sentence "there is no unique or exclusion constraint matching the ON CONFLICT specification". #8445's recognition was SQLite-only, so on Postgres the raw error escaped and the caller got statement text with no code to branch on. Recognition graduates to `isUnbackedConflictTargetError` in @objectstack/types, beside — and deliberately separate from — `isUniqueViolationError`, which answers the opposite condition. One measured message limb per dialect; the `code` channel is left unread because it over-matches on Postgres (42P10 is invalid_column_reference, which an out-of-range ORDER BY position also raises) and is generic on SQLite. MySQL cannot raise the condition: knex compiles onConflict().merge() there to ON DUPLICATE KEY UPDATE, which takes no conflict target. Pinned from the compiled statement; the live MySQL cell is declared un-run, not omitted. One clause of the refusal wording moved on BOTH faces — "SQLite refuses the statement" to "the database refuses the statement" — because naming SQLite to a Postgres operator points at the wrong engine. #5240's one-wording rule and #8568's cross-face parity pin are what keep the two faces together. 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 3 package(s): 11 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:
|
os-zhuang
commented
Aug 14, 2026
ACCEPT — reviewed against the tree at This card's whole deliverable was question (1), so I did not accept the Postgres numbers on the report's word. I started my own throwaway PG 16.13 cluster and raised the condition: Byte-for-byte the sentence that went into the predicate's regex, and So Verified in the diff, independently of the description:
On #8590 — filing it instead of fixing it was the right call, and the reasoning is better than "out of scope": the fix moves a consolidated predicate's verdicts across six packages, and the naive narrowing would silently drop Postgres' One thing I can resolve that the dev could not. The report declines to file the MySQL-behaviour card because no # .github/workflows/ci.yml:583image: mysql:8.0# :673OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformanceSo that half is measurable — in CI, not on a dev box. Nothing to change in this PR (the file here runs under Test Core, which has no such service, so its declared-un-run cell is honest for the job that runs it). I am filing the follow-up as a card whose first deliverable is the measurement, pointed at that runner, rather than one asserting what MySQL does. Holding ready + queue until all 24 checks report green. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8567
The card's first deliverable was the measurement, and it decided the rest. Both questions answered, in the order the card set.
(1) MEASURE — Postgres, raised for real
A throwaway PostgreSQL 16.13 cluster from the system PG16 binaries (
initdb+pg_ctl, no container runtime — the docker daemon is unreachable here), driven through knex + pg, the same pathSqlDriver.upserttakes: a table with no unique index on the target column, thenupsert(obj, row, ['email']). Read off the thrown error object, verbatim:The card's stated expectation held — SQLSTATE
42P10, and prose matching "there is no unique or exclusion constraint matching the ON CONFLICT specification" — so nothing had to be overturned. knex wraps it the same way it wraps SQLite's: STATEMENT, then-, then the server's own sentence.42P10IS reachable by other conditions, which the card asked to be checked and which changes the design. Measured on the same cluster, same session:42P10isinvalid_column_reference, not "unbacked conflict target". So a code-only predicate over-matches, and the message must carry the verdict — the code channel is left deliberately unread on both dialects (SQLite's is the genericSQLITE_ERROR). Also measured: a partial unique index (... where email is not null) raises the identical 42P10 and sentence, which is correct — a partial index cannot serve as an arbiter.MySQL — the sub-question that needs no server, answered from the compiled SQL. knex compiles the driver's exact call on the
mysql2dialect toON DUPLICATE KEY UPDATE, which takes no conflict target:The named keys never leave the process, so the server is never asked to find an index for them: the condition cannot arise on MySQL. What it does instead — merging on whichever unique key the row happens to collide with — is the separate defect the card anticipated; not fixed here, and not asserted here either, because nobody has watched a MySQL server do it. The live MySQL cell is declared un-run through
live-dialect-matrix.testkit.tsrather than dropped.(2) THEN the home — the dialects share a shape, so the predicate graduates
Two dialects, two unrelated sentences, one channel: that is precisely the per-dialect alternation
UniqueViolationSignaturealready encodes.isUnbackedConflictTargetErrornow lives in@objectstack/typesbesideisUniqueViolationError, one measured limb per dialect, no new dependency edge.It stays a separate predicate.
isUniqueViolationErroranswers the opposite condition, and putting the two side by side is what puts the warning where someone reaching for the wrong one will read it.driver-turso's private copy deliberately stays: that package does not depend on@objectstack/types, and its face speaks libsql/SQLite and only ever will, so the Postgres limb could never apply. Noted in place with the condition under which it should be deleted.The wording moved one clause, on both faces
"…and SQLite refuses the statement" is now "…and the database refuses the statement". Once recognition covers Postgres, that sentence pointed a Postgres operator at the wrong engine. Per #5240 both faces carry one wording, so
driver-turso's copy moved in the same commit. Nothing else in the refusal changed.The disjointness suite went red on its first run, and the measurement won. On SQLite,
isUniqueViolationErroralready claims the unbacked-target error — itsunique constraintlimb matches SQLite's missing-index sentence, which ends...any PRIMARY KEY or UNIQUE constraint. Postgres escapes only on word order. Measured on real driver errors:Filed as #8590 and left alone here — narrowing that predicate moves verdicts in six consuming packages. It is latent today (
upsertis the only site compiling a caller-supplied conflict target, and recognition runs first in that catch), but a comment insql-driver.tsasserted the opposite as fact and reasoned from it; that comment is corrected in place, and the ordering is now documented as load-bearing. The test pins both predicates' verdicts per dialect, so #8590's fix announces itself instead of landing silently. #8590 is not addressed by this PR.Reverse verification — direction predicted before each leg
3 failed | 11 passed | 1 skipped— envelope, wording and leak pins red on the live-postgres cell only; all 6 SQLite pins green; both positive controls green on both cells, which is what proves the limbs are independent rather than one regex covering both.1 failed | 9 passed, the failure beingexpect(localErr.message).toBe(remoteErr.message). Restored; both faces moved together, pin green.driver-sqldist rebuild trap from drivers(turso): the unbacked-conflict-target refusal has no cross-face parity pin — the two wordings can drift without a test noticing #8568 was respected —driver-tursowas only ever run against a freshly builtdistcarrying the reword (verified by grepping the built bundle).Tests
Full suites, live Postgres cell provisioned and non-vacuous (server
Asia/Shanghai, processTZ=America/New_York, so the three-way zone skew guard passes rather than the matrix proving nothing):Gates derived from the actual changed paths via
scripts/pm/dispatch-gates.mjs, all green:check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:error-code-casing,check:nul-bytes,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset.Changeset: a real
patchacross the three packages. The accept/reject set does not move — the same upserts fail, they fail legibly — but a Postgres caller's response body changes from raw statement text to a coded envelope, which is consumer-visible.Generated by Claude Code
Generated by Claude Code