Skip to content

fix(drivers): recognise the unbacked conflict target on Postgres too, from a measured PG 16.13 (#8567) - #8591

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8567-unbacked-conflict-target-dialects
Aug 14, 2026
Merged

fix(drivers): recognise the unbacked conflict target on Postgres too, from a measured PG 16.13 (#8567)#8591
os-zhuang merged 1 commit into
mainfrom
claude/issue-8567-unbacked-conflict-target-dialects

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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 path SqlDriver.upsert takes: a table with no unique index on the target column, then upsert(obj, row, ['email']). Read off the thrown error object, verbatim:

name = error (constructor DatabaseError)
code = "42P10" (typeof string)
severity = "ERROR"
routine = "infer_arbiter_indexes"
constraint = undefined
detail = undefined
status = undefined
message = insert into "plain" ("email", "id", "title") values ($1, $2, $3)
on conflict ("email") do update set "title" = excluded."title"
- there is no unique or exclusion constraint matching the ON CONFLICT specification

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.

42P10 IS reachable by other conditions, which the card asked to be checked and which changes the design. Measured on the same cluster, same session:

select id from plain order by 7 -> code=42P10 "ORDER BY position 7 is not in select list"
select id from plain group by 9 -> code=42P10 "GROUP BY position 9 is not in select list"
select nope from plain -> code=42703 (a different code entirely)

42P10 is invalid_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 generic SQLITE_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 mysql2 dialect to ON DUPLICATE KEY UPDATE, which takes no conflict target:

mysql2 -> insert into `plain` (`email`, `id`, `title`) values (?, ?, ?)
on duplicate key update `title` = values(`title`) <- no conflict target
pg -> insert into "plain" (...) values ($1, $2, $3)
on conflict ("email") do update set "title" = excluded."title"

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.ts rather 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 UniqueViolationSignature already encodes. isUnbackedConflictTargetError now lives in @objectstack/types beside isUniqueViolationError, one measured limb per dialect, no new dependency edge.

It stays a separate predicate. isUniqueViolationError answers 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.

⚠️ Found while pinning: a pre-existing inversion, filed not fixed

The disjointness suite went red on its first run, and the measurement won. On SQLite, isUniqueViolationError already claims the unbacked-target error — its unique constraint limb 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:

sqlite unbacked-target error -> isUniqueViolationError = true <- wrong
pg unbacked-target error -> isUniqueViolationError = false <- correct

Filed as #8590 and left alone here — narrowing that predicate moves verdicts in six consuming packages. It is latent today (upsert is the only site compiling a caller-supplied conflict target, and recognition runs first in that catch), but a comment in sql-driver.ts asserted 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

Tests

Full suites, live Postgres cell provisioned and non-vacuous (server Asia/Shanghai, process TZ=America/New_York, so the three-way zone skew guard passes rather than the matrix proving nothing):

@objectstack/types 11 files, 280 tests passed
@objectstack/driver-sql 96 files, 1703 tests passed | 1 skipped (the MySQL cell)
@objectstack/driver-turso 37 files, 986 tests passed
typecheck: types, driver-sql, driver-turso — all clean

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 patch across 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

…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
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 12:27am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/types.

11 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-sql, @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-sql, @objectstack/driver-turso)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/anatomy.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/types)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/driver-sql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

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

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — reviewed against the tree at 91bdacb, and the measurement re-run independently rather than taken from the report.

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:

ERROR: 42P10: there is no unique or exclusion constraint matching the ON CONFLICT specification
LOCATION: infer_arbiter_indexes, plancat.c:920

Byte-for-byte the sentence that went into the predicate's regex, and infer_arbiter_indexes matches the reported routine. The three follow-on claims corroborate too:

select id from plain order by 7 -> ERROR: 42P10: ORDER BY position 7 is not in select list <- over-match, real
select nope from plain -> ERROR: 42703: column "nope" does not exist <- different code
create unique index ... where email is not null;
insert ... on conflict (email) -> ERROR: 42P10: there is no unique or exclusion constraint… <- partial index is no arbiter

So 42P10 really is reachable from an unrelated condition, and the decision to leave the code channel unread on both dialects and let the message carry the verdict is forced by measurement, not stylistic. (Aside: initdb refuses to run as root, so this needed an unprivileged user — a real hurdle, cleared the same way on both sides.)

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' violates unique constraint "...", which that limb has covered since it was inherited verbatim. Correcting the sql-driver.ts comment that asserted the opposite as fact and reasoned from it was the necessary half to do here — a wrong comment that a future author trusts is how the latent bug becomes live.

One thing I can resolve that the dev could not. The report declines to file the MySQL-behaviour card because no mysqld exists in the dev container and describing unobserved behaviour is the evidence standard #8445 ruled out — correct reasoning, incomplete premise. CI provisions a live MySQL:

# .github/workflows/ci.yml:583image: mysql:8.0# :673OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance

So 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

@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 00:53
@os-zhuang
os-zhuang added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit 694c350Aug 14, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8567-unbacked-conflict-target-dialects branch August 14, 2026 01:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drivers(sql): the unbacked-conflict-target refusal is SQLite-only — Postgres and MySQL still answer the raw driver error

2 participants

@os-zhuang@claude