Repository navigation
drivers(sql): on MySQL an upsert with no conflictKeys — or one naming the primary key — still merges on a unique key the caller never named #8807
Description
Activity
Triage: lands in
packages/drivers/driver-sql⇒domain:drivers(filer's label kept); typeBug; escalated toneeds-user-decision.Rationale: all three options change
SqlDriver.upsert's pre-flight and nothing else — the callers are consumers, not landing sites — so the lane is determinable now and the ruling does not select it.Not a duplicate of #8755
Checked deliberately, because the titles are one clause apart. #8755 (PR #8806, in flight) refuses a caller-named non-primary conflict target; this card is the residue that scoping leaves: the
conflictKeys-less call and the['id']call, which compile byte-identically. Filed by the dev implementing #8755 rather than folded in — the right call, since the argument here cannot borrow #8755's ("the platform promised to honour a target it cannot") and has to be a wider statement about whatupsertmeans.Premises re-verified on
origin/main@d09d0fd, ⛔ not inheritedclaim reading the archiver call option A would refuse verbatim: packages/objectql/src/lifecycle/lifecycle-service.ts:1123—await cold.upsert(object, row, ['id']);ON DUPLICATE KEY UPDATEcarries no targetpresent on mainatsql-driver.ts:5076-5128and pinned bysql-driver-upsert-conflict-target-dialects.test.ts:478([#8567] MySQL: onConflict().merge() compiles the conflict target away)the merge-ALL fallback sql-driver.ts:5277-5296, matching #8740's holdrefuseAmbiguousConflictTargetnot on main— it is PR #8806's, as the card states. Zero-hit counter-probed against the adjacent names above, which do hit, so the absence is a real reading and not a broken query.I did not re-run the live MySQL measurement; it is reported with the dialect, version, seed and the row read back, and the SQLite contrast is the control. Treating it as sound.
Four-facet card face
- ① Platform long-term coherence — A makes
upsertmean one thing on every dialect: a merge lands only on a key the caller named or the identity it supplied. That shrinks a special case. C keeps a per-dialect exception alive as documentation, i.e. one more thing every author must know. B is the least coherent of the three: it makes the refusal data-dependent, so two identical calls differ by payload — a new special-case shape, not a smaller one. - ② Measured business pull — the defect is measured on live MySQL 8.0.46, and it is silent, which is exactly why no user report exists. Against that: A has a measured first-party cost — the lifecycle archiver above is a real caller it would refuse for every object carrying a declared unique business column. So this facet reads both ways and is the one to measure before ruling (see below).
- ③ AI-agent error-resistance — this is the strongest facet, and it points at A/B. An agent authoring an app with a natural business key (
email,tax_id,code) gets no signal: the write succeeds, and a different row is rewritten. "One UNIQUE key per table on MySQL" as advice (option C) is a rule nothing enforces — the shape this project's guardrails exist to avoid. - ④ Startup scope discipline — C is free today. A costs a first-party migration (the archiver needs an explicit target or an opt-out). B costs a payload inspection on the hot path plus a permanently data-dependent accept set — the largest standing obligation of the three despite being the smallest accept-set change.
Facets split ①③ (act) vs ②④ (cost) ⇒ escalated, ⛔ not adjudicated. It is also on the manual floor independently: A and B each state a new contract about what
upsertaccepts, with no declared contract to pull back to, so the mechanical border test lands it outside the delegated lane whatever the facets said.Recommendation — offered, falsifiable, ⛔ not a ruling
A, narrowed to tables that actually carry a non-primary UNIQUE key, with the archiver's call site given an explicit conflict target or a documented opt-out. The pre-flight already probes table keys for #8621, so the probe is not new cost; and narrowing to "table has a rival unique key" avoids B's data-dependence while refusing far less than a blanket A.
Falsifier, and the measurement I recommend before ruling: how many objects the lifecycle archiver actually touches declare a non-primary unique field. If that set is large, A's blast radius is first-party and the ruling should probably be C-plus-a-loud-doc rather than a refusal. I have not measured it.
One thread deliberately left open
The
#6943autonumber re-seed reachability question in the card body is unmeasured and I did not measure it either. It should not block this ruling — if it confirms, it is a second defect of the same family and wants its own card, not a wider scope on this one.
Generated by Claude Code
- ① Platform long-term coherence — A makes
os-project-manager commented
on Aug 15, 2026 CollaboratorMore actionsMaintainer ruling — the principle is adopted; the drivers lane chooses the enforcement
Provenance: maintainer, live PM chat 2026-08-15 (session
session_01LXPH2ApQmyHYeZGHfYv7Xs), verbatim 「接受你的所有建议。」, accepting the 18-card decision-box analysis. This card's accepted recommendation, recorded as a two-part ruling:1. Contract principle (ruled): an
upsertmust never modify a row whose identity the caller did not supply and whose conflict key it did not name. The MySQL silent cross-row merge violates it and is a defect, not a documented quirk.2. Enforcement (delegated to the drivers lane, bounded): the lane picks the cheapest enforcement that upholds the principle, with these exclusions — ⛔ not blanket A (refusing every
conflictKeys-less upsert on any table with a non-primary UNIQUE key would refuse the platform's own lifecycle archiver wholesale), ⛔ not C (documented-only is not a terminal state for a data-corruption class), and B's payload-dependent refusal (two identical calls, one refused) is disfavoured. The triage escalation's A-narrowed shape is the leading candidate: refuse only on tables that actually carry a rival non-primary UNIQUE key, with the archiver call site given an explicit conflict target or a documented opt-out; a post-hoc identity check (merged-row id ≠ supplied/minted id ⇒ error inside the transaction) is an acceptable alternative if measurement favours it.Premise before implementing (the escalation's falsifier, adopted): measure how many archiver-touched objects declare a non-primary unique field. If that set is large, the chosen enforcement must handle the archiver first-party in the same PR — not by widening what merges, but by fixing its call sites; if that turns out impractical, STOP and report with the number rather than shipping a refusal that breaks archival.
Out of scope, per the escalation: the unmeasured #6943 autonumber re-seed reachability thread — if confirmed, it is its own card.
needs-user-decision→pm:queuein the same write. Size/model: M–L,opus; the refusal is an accept-set change on the driver contract — pin both branches (refused shape + still-merging legitimate shape) per dialect.
Generated by Claude Code
Claim: PM loop
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8807-upsert-unnamed-unique-key-merge
Worktree:objectstack-issue-8807
Domain:domain:drivers
File surface:packages/drivers/driver-sql/src/sql-driver.ts(theupsertpre-flight) + its pins, pluspackages/objectql/src/lifecycle/lifecycle-service.ts(the archiver call site the ruling requires be handled first-party). Stop on breach; explain in the report.
Container & model:M–L,mode:subagent,model: opus(as the ruling specifies)
Serial constraints cleared: discharged, and the triage note is now stale — see below.driver-sqlis otherwise uncontended: #8927 (#8790) merged at 23:33Z, and the one live sibling (#8933 / #8907) is inmetadata-protocol, touching neither package on this surface.⚠️ The serial constraint this card recorded has been discharged — measured, not assumedTriage's premise table (03:50Z) recorded:
refuseAmbiguousConflictTarget— not onmain— it is PR #8806's, as the card states.Measured just now on
origin/main@716ac9bf8: it IS on main — 3 occurrences insql-driver.tsplus its dialect pin. PR #8806 (#8755) has landed. Probe validated with a counter-term in the same file that must hit, so the positive is a real reading.⇒ Two consequences the dispatch carries:
- Nothing is in flight in this function. No serialization needed.
- ⛔ The dev inherits fix(driver-sql): refuse a MySQL upsert whose named conflict target another unique key can absorb (#8755) #8806's pre-flight and must build ON it, not beside it. This card is explicitly the residue fix(driver-sql): refuse a MySQL upsert whose named conflict target another unique key can absorb (#8755) #8806's scoping left — the
conflictKeys-less call and the['id']call, which compile byte-identically — so a second, parallel refusal path in the same pre-flight would be the "a guard that exists twice will disagree with itself" shape.
The ruling, carried as two parts because it is two parts
1. Principle — RULED, ⛔ not re-adjudicable:
an
upsertmust never modify a row whose identity the caller did not supply and whose conflict key it did not name.The MySQL silent cross-row merge violates it and is a defect, not a documented quirk.
2. Enforcement — DELEGATED to this lane, bounded. The lane picks the cheapest enforcement upholding the principle, with the ruling's exclusions binding: ⛔ not blanket A (it would refuse the platform's own lifecycle archiver wholesale), ⛔ not C (documented-only is not a terminal state for a data-corruption class), and B's payload-dependent refusal (two identical calls, one refused) is disfavoured. A-narrowed is the leading candidate; a post-hoc identity check (merged-row id ≠ supplied/minted id ⇒ error inside the transaction) is an acceptable alternative if measurement favours it.
⇒ I am not pre-picking between those two. The dev measures and chooses within the bounds, and states which and why — that is what "delegated to the lane" means, and picking it from here without the measurement would waste the delegation.
The premise that must be measured BEFORE implementing
The ruling adopts the escalation's falsifier verbatim:
measure how many archiver-touched objects declare a non-primary unique field. If that set is large, the chosen enforcement must handle the archiver first-party in the same PR — not by widening what merges, but by fixing its call sites; if that turns out impractical, STOP and report with the number rather than shipping a refusal that breaks archival.
That is why
lifecycle-service.tsis in the declared file surface rather than left as a follow-up.⛔ Out of scope, per the escalation and the ruling: the unmeasured #6943 autonumber re-seed reachability thread. If it confirms, it is its own card — ⛔ not a widening of this one.
Generated by Claude Code
{ "issue": 8807, "status": "done", "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge", "pr": "https://github.com/objectstack-ai/objectstack/pull/8940", "premise_still_valid": true, "summary": "Enforcement chosen: the POST-HOC IDENTITY CHECK, not A-narrowed — and the measurement drove it. Zone 2 falsifier measured FIRST: exactly 2 objects in the repo declare `lifecycle.archive` (sys_audit_log, sys_metadata_audit) and ZERO declare a non-primary unique field, so the STOP condition does not fire. The decisive measurement was a second one: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival — 'A-narrowed to tables carrying a rival key' and 'blanket A' are literally the same set here, meaning A-narrowed collapses into the option the ruling excluded by name and would refuse `upsert(object, row)` on all 43 first-party objects declaring a unique field. The post-hoc check has no such cost and is exact rather than heuristic: since `id` is insert-only on the merge path (#8622), 'no row carries the supplied id after the statement' is a BICONDITIONAL for 'the merge landed on a row the caller never identified' — zero false refusals. It runs inside a transaction so the wrong write is ROLLED BACK ('never modify' is not satisfied by noticing afterwards), and only on MySQL tables that actually carry a rival key, so ordinary upserts keep their single autocommitted round trip. Built ON #8806's pre-flight as instructed — same introspection, same cache, same judge(), a third verdict rather than a second guard. Premise re-verified on live MySQL 8.0.46 for BOTH shapes before implementing. Archiver handled first-party: `cold.upsert(object, row, ['id'])` needs no change and is correct by construction (it supplies each row's own id); the measurement and reasoning are recorded at the call site, and its exact shape is pinned on the hostile two-unique-key table. The issue body showed NO sanitizer truncation.", "tests": "All quoted from the union run at final HEAD af3147f76. (1) LIVE MySQL 8.0.46 raised in-container (mysqld 8.0.46-0ubuntu0.24.04.3, time_zone '+08:00', OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance — the CI-shaped URL): sql-driver-upsert-conflict-target-dialects.test.ts 'Test Files 1 passed / Tests 35 passed | 2 skipped'. (2) driver-sql whole package on SQLite: 'Test Files 99 passed | 4 skipped (103) / Tests 1724 passed | 56 skipped'. (3) Consumer sweep, DOWNSTREAM direction (--filter '...@objectstack/driver-sql' = 48 consumer packages; prefix form, not the upstream suffix): driver-turso 'Tests 1002 passed', driver-sqlite-wasm 'Tests 394 passed' (both SqlDriver subclasses inheriting this path), spec 'Tests 10711 passed', objectql 'Tests 1 failed | 3694 passed' — the one failure is PRE-EXISTING and already filed as #8937 (engine-temporal-comparand-door.test.ts hard-codes now=2026-08-15 and went red at UTC midnight; proved not mine: my entire objectql diff is 100% comment lines, verified by filtering the diff for non-comment additions and getting zero). (4) Typecheck green: driver-sql, objectql, spec. (5) TEST_DEBT measured on a BUILT workspace (full turbo build of packages/* first): 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number'. No ceiling raised. Gate reports a pre-existing -1 surplus in @objectstack/lint (a package I did not touch), lowerable but explicitly 'not an error'. (6) ABLATION, direction predicted BEFORE running, both matched, fix committed first so restore came out of a real commit. A: verifyIdentity forced false -> predicted the 4 refusal pins red and every control green; observed exactly '4 failed | 31 passed', text: \"AssertionError: the seeded row survived with the OVERWRITTEN email — the refusal was reported but not rolled back, so the corruption this card exists to stop still happened: expected 'e@b.com' to be 'd@b.com'\". B: transaction wrapper disabled but check kept -> predicted envelope pins stay GREEN and only stored-row pins go red, isolating the rollback; observed exactly '2 failed | 33 passed' (pins 1 and 4 green, 2 and 3 red). Restored to byte-identity: git status clean, 0 ablation markers. (7) Gates re-derived from ACTUAL changed paths via scripts/pm/dispatch-gates.mjs and run as a union at af3147f76 — all green: nul-bytes, changeset-gate-self-tests, objectui-changeset, docs-audit-scope, role-word, test-source-alias, type-source-resolution, merge-driver, spec-parsed-alias, durability-log-level, error-code-casing, query-options-erasure, type-check-coverage, type-check-debt --re-measure, cross-package-test-inputs, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, engine-split-ratio, doc-formula-expressions, migration-registry. Added beyond the prompt's named families: durability-log-level and engine-split-ratio (via the objectql edit), spec-parsed-alias / merge-driver / doc-formula-expressions / cross-package-test-inputs / migration-registry (via the spec ledger entry). No dogfood ablation in this card, so no dist rebuild claim is made.", "open_questions": [], "out_of_scope_findings": [ "already filed as #8937 (found independently, searched before filing, not duplicated): engine-temporal-comparand-door.test.ts hard-codes now=2026-08-15 and the {30_days_ago} resolution ignores the injected context.now, so it went permanently red at UTC midnight — it is the one red in the objectql consumer sweep and is unrelated to this card", "NOT filed, reported instead: check:type-check-debt reports a -1 surplus in @objectstack/lint (records 20, tsc now reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, and the gate itself prints it on every run as 'not an error' — self-announcing rather than hidden, so filing looked like noise. Flagging for PM triage rather than sitting on it.", "#6943 autonumber re-seed reachability: left untouched and unmeasured, as the ruling and the escalation both required." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 16, 2026 Supersedes the earlier report on this card — CI-red fix round (
check:tenant-chokepoint).{ "issue": 8807, "status": "done", "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge", "pr": "https://github.com/objectstack-ai/objectstack/pull/8940", "premise_still_valid": true, "summary": "Fixed the red gate the FIRST way, as instructed — bound the builder and applied the scope on it; check-tenant-chokepoint.mjs is untouched. The PM was right that this was a real question and not a lint nit, and it changed the code twice. (1) The identity read is now tenant-scoped, but to the tenant the row was WRITTEN under, not the caller's active org. Scoping to the active org would false-refuse a documented authority: injectTenantOnInsert never overwrites an explicit tenant ('admins writing to a specific tenant via raw row data keep that authority'), so a caller whose active org is org_a can legitimately land a row in org_b, and an active-org-scoped probe would miss the row it just wrote. Both readings are now pinned and ablation C proves the pin discriminates. For the archiver and any non-tenanted object the scope is a NO-OP by applyTenantScope's own contract (it early-outs on an empty tenantId, and the archiver passes no options at all) — so archival is untouched, which was the ruling's red line. (2) Fixing the first complaint surfaced a second: my earlier refactor had chained .insert() off getBuilder() inside the new closure, which hid the INSERT door from the gate's classifier (it detects a write by seeing .insert used on the BINDING). Restored the two-step bind — honest, since insert-side tenancy is injectTenantOnInsert's job. I also measured the one fact the PM's concern implied I was assuming: whether a pre-existing row carrying our id could MASK a merge that went elsewhere when the payload collides on the PK and a rival key at once. Measured on live MySQL 8.0.46 — the PRIMARY KEY row wins (R1 merged, R2 untouched, ROW_COUNT=2) — so the biconditional holds and the masking case does not arise. Gate list correction accepted: check:tenant-chokepoint is content-keyed (it scans builder shapes), so dispatch-gates.mjs structurally cannot name it; I ran the ENTIRE 48-gate ESLint job this round rather than a derived subset.", "tests": "All at final HEAD bd0f45cd7, tree clean. (1) check:tenant-chokepoint: '✓ 20 getBuilder() binding(s) across 3 file(s); every read builder routes through applyTenantScope(), every unscoped one is an insert.' (2) The ENTIRE ESLint job — all 48 gates extracted from lint.yml's `lint` job and run individually — ALL PASS, including pnpm lint, check:slot-lookup, check:verify-stand-in, check:engine-double-contract, check:published-files, check:pm-dispatch-gates, check:partof-closing-keyword. (3) LIVE MySQL 8.0.46: sql-driver-upsert-conflict-target-dialects.test.ts 'Test Files 1 passed / Tests 38 passed | 2 skipped' — up from 35, the three new pins being the tenanted refusal, the tenanted merge, and the admin cross-org write. (4) sql-driver-tenant-scope-read-doors.test.ts 'Tests 21 passed' — run explicitly because the chokepoint gate's own docblock names it as the other half of its contract ('the gate proves the call is there; only the fixture proves it works'). (5) driver-sql whole package on SQLite: 'Tests 1724 passed | 56 skipped'. (6) Subclasses: driver-turso 'Tests 1002 passed', driver-sqlite-wasm 'Tests 394 passed'. ⚠️ Both first reported 25 test files failed with 'no tests' — that was the missing-dist trap, not a regression: the worktree had been torn down and recreated, so no package had a dist/. After building the closures both are green at the counts above; recording it because a stale/absent dist reads exactly like real breakage. (7) TEST_DEBT on a BUILT workspace: '--re-measure: OK — 33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number.' No ceiling raised. (8) ABLATION C, new this round, direction predicted first: scoping switched to options.tenantId (the naive active-org reading) -> predicted EXACTLY ONE red, the admin cross-org pin, with the other two tenanted pins staying green; observed '1 failed | 37 passed', text: \"AssertionError: the identity read is scoped to the CALLER's active org rather than the tenant the row was written under, so a deliberate cross-org admin write reads as a cross-row merge\". Ablations A and B from the previous round still stand. All restored to byte-identity (git status clean, 0 ablation markers). (9) MEASUREMENT behind the biconditional, raw SQL on live MySQL 8.0.46: rows R1(id=R1,tax_id=T-1) and R2(id=R2,tax_id=T-2), inserting (id=R1,tax_id=T-2) which collides with BOTH -> 'R1 -> title=MERGED, R2 -> untouched, ROW_COUNT()=2'. The primary key is matched first.", "open_questions": [], "out_of_scope_findings": [ "#8937 (I found and filed it last round): engine-temporal-comparand-door.test.ts went red at UTC midnight. Confirmed still the ONLY red in the objectql sweep, still unrelated (my objectql diff is 100% comment lines). Per the PM's instruction this round: not fixed, not worked around, not allowed to hold this PR.", "NOT filed, reported instead: check:type-check-debt reports a -1 surplus in @objectstack/lint (records 20, tsc reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, and the gate prints it on every run as explicitly 'not an error' — self-announcing rather than hidden. PM's call.", "#6943 autonumber re-seed reachability: untouched and unmeasured, as ruled." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 16, 2026 Supersedes the earlier reports — merge-conflict resolution round (
MERGE_CONFLICTdequeue).{ "issue": 8807, "status": "done", "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge", "pr": "https://github.com/objectstack-ai/objectstack/pull/8940", "premise_still_valid": true, "summary": "Merge conflict resolved by REGENERATION in the mandated order, and the branch is now on top of the newest main. Ran scripts/pm/os-regen-merge.sh; it stopped at step 1 as designed because packages/spec/src/migrations/registry.ts is NOT routed to merge=os-regen in .gitattributes, so it conflicts textually rather than silently. registry.ts was the ONLY conflict. Resolved it the generated way rather than by hand: took origin/main's side, COMMITTED THE MERGE FIRST (commit 1286d2993), and only then ran gen:migration-registry — the ordering trap the script's header warns about. Verified the regeneration was doing real work rather than a no-op: before it, the registry body contained engine-dotted-filter-refused and NOT my id; after it, 97 semantic entries and both present. Both intents then verified by exact name in BOTH places, as required. Also checked the trap the script calls out beyond the index — that #8936's IMPLEMENTATION body survived, not just its entry: my branch's net diff versus origin/main across packages/objectql and packages/metadata-protocol is exactly my 26-line lifecycle comment and nothing else, and the dotted-filter implementation in filter-comparand-shape.ts is intact. Then, on the PM's warning that main had moved again: main had advanced 10 further commits (a8189aef4 -> b50c0ef27); computed the file overlap with my diff, found NONE, merged again cleanly, re-verified both ids and the registry gate, and pushed. Final head bf7e01142. No pin was weakened, deleted or adjusted at any point.", "tests": "All at final head bf7e01142 unless noted; tree clean, everything pushed. (1) BOTH ADR-0087 ids present in entry file AND registry body, re-verified after the SECOND merge too: driver-sql-upsert-cross-row-identity-merge-refused -> entry PRESENT / registry PRESENT; engine-dotted-filter-refused -> entry PRESENT / registry PRESENT. check:migration-registry PASS ('src/migrations/registry.ts is current (97 semantic, 30 retired-key, 54 retired-def)'). (2) RATCHETS re-run rather than assumed, on a BUILT workspace: check:type-check-debt --re-measure OK — '33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number'; check:query-options-erasure PASS. The merge imported #8936's test surface and neither ratchet moved above its ceiling. (3) The ENTIRE 48-gate ESLint job re-run at the merged head bf46f6379 — ALL PASS, check:tenant-chokepoint included; re-confirmed at the final head after the second merge (tenant-chokepoint, nul-bytes, migration-registry all PASS). (4) The two BRAND-NEW gates that landed on main in the second merge window were run explicitly on my branch: check-examples-live-imports.mjs PASS, check-release-page-status.mjs PASS. (5) LIVE MySQL 8.0.46 at the final head: 'Test Files 1 passed / Tests 38 passed | 2 skipped'. #8936 added a dotted-filter refusal at both doors; no fixture of mine uses a dotted key, so nothing met it — checked rather than assumed. (6) Full sweep post-merge: driver-sql 1724 passed | 56 skipped (re-confirmed at final head), driver-turso 1002 passed, driver-sqlite-wasm 394 passed, spec 10719 passed (406 files), metadata-protocol 1512 passed (106 files). (7) ⭐ objectql is now 211 files / 3721 tests ALL PASSED — zero failures. The #8937 UTC-midnight red is GONE: the merge picked up main's fix for it, so the one pre-existing failure I reported in both previous rounds no longer exists on this branch. (8) driver-sql typecheck PASS. NOTE on process: two foreground runs hit the harness's 10-minute call cap (exit 143) and one lock acquire returned exit 99. I did not treat any of them as failure or end a turn on them — I inspected the lock with fuser each time, found first my own orphaned child and later a SIBLING agent's run (issue-8908, dogfood/http-conformance/cli), waited actively in-turn and re-acquired, splitting the package sweep into single-package runs to fit the cap. Nothing was killed by name; the sibling's worktree was left untouched.", "open_questions": [], "out_of_scope_findings": [ "#8937 is RESOLVED as of this merge — main's fix landed and objectql is fully green on this branch (3721/3721). Withdrawing it as a caveat on this PR; it no longer holds anything.", "NOT filed, reported instead: check:type-check-debt still reports a -1 surplus in @objectstack/lint (records 20, tsc reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, printed by the gate on every run as explicitly 'not an error'. PM's call.", "#6943 autonumber re-seed reachability: untouched and unmeasured, as ruled." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 1, 2026 - added a commit that references this issue
on Oct 7, 2026
Found while implementing #8755 (PR #8806). Filed unassigned rather than folded in: #8755's ruling scopes its refusal to a caller-named non-primary conflict target, and this is the residue that scoping leaves behind. It is documented in that PR (driver docs +
refuseAmbiguousConflictTarget's docblock) rather than hidden, but documented is not fixed.The condition
ON DUPLICATE KEY UPDATEcarries no conflict target, so on MySQL the merge lands on whichever UNIQUE key the row collides with first. #8755 refuses that when the caller names a non-primary target. Two shapes remain, and they are the same statement:upsert(object, row)— noconflictKeys. The pre-flight deliberately never probes this path (the driver's own['id']).upsert(object, row, ['id'])— the primary key named explicitly. drivers(sql): on MySQL an upsert still merges on a unique key the caller never named — even when the named conflict target IS backed #8755 leaves this merging on purpose: it compiles byte-identically to the line above, so refusing one and merging the other would make the accept set a property of how the caller typed the same statement, and the refusal's stated remedy ("drop or rename the extra key") is not available for a primary key.In both, the driver mints or is handed an
idthat usually does not collide — while a business UNIQUE key on the same table can, and MySQL merges there instead.Measured — live MySQL 8.0.46
Ubuntu noble
mysql-server,mysqld --daemonize,time_zone='+08:00', through the same knex +mysql2pathSqlDriver.upserttakes. Table carriesPRIMARY KEY (id),UNIQUE KEY (email),UNIQUE KEY (tax_id):The identical pair on SQLite raises
UNIQUE constraint failed: ….tax_idand leaves the seeded row untouched (also measured, same session).Note the row identity IS preserved (
idis insert-only since #8622) — this is not that defect. What is wrong is which row was updated.Why it is not obviously the same call as #8755
The ruled refusal rests on a caller who named a target: the platform promised to honour it and cannot. Here nobody named anything, so the argument has to be different — something like "an upsert should never merge into a row whose identity the caller did not supply and whose conflict key it did not name". That is a wider statement about what
upsertmeans, not a MySQL detail, and it wants its own adjudication.Options, none of them free:
conflictKeys-less upsert on a MySQL table carrying any non-primary UNIQUE key. Widest accept-set change of the three; it would refuse the platform's own lifecycle archiver (lifecycle-service.ts,cold.upsert(object, row, ['id'])) for every object with a declared unique business column.One unverified thread worth a look while adjudicating
SqlDriver.upsert's #6943 re-seed logic states that "the tenanted autonumber lives under a DIFFERENT unique index, so its violation is still raised and still reaches here", and re-seeds the counter when it can prove the collision was its own. On MySQL a collision on that index would be merged, not raised — so the re-seed path may be unreachable there, and a burned/stale autonumber may merge into the row that owns the number instead. Not measured; it needs an object with anauto_numberfield on live MySQL to confirm or kill.Related: #8755 (the ruled half, PR #8806), #8621 (the unbacked-target pre-flight), #8592 (the original live-MySQL measurement), #8622 (
idinsert-only, which is why identity survives above), #6943 (the autonumber re-seed thread).Generated by Claude Code