You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
drivers(sql): what MySQL does with a conflict target it cannot honour is unmeasured — knex compiles the target away, so the refusal cannot arise there #8592
Filed out of #8567, which measured Postgres and generalised the unbacked-conflict-target refusal to it. Unassigned. Observation-class and deliberately unmeasured — the first deliverable of this card is the measurement, not a fix.
SqlDriver.upsert passes conflictKeys to knex's onConflict(...).merge(...). On the mysql2 dialect knex compiles that 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`) <- named keys are gone
pg -> insert into "plain" (...) values ($1, $2, $3)
on conflict ("email") do update set "title" = excluded."title"
The caller's keys never leave the process, so the server is never asked to find an index for them. The unbacked-conflict-target condition cannot arise on MySQL, and isUnbackedConflictTargetError correctly carries no MySQL limb.
The open question this leaves
If the target is compiled away, MySQL does not refuse — it merges on whichever unique key the row happens to collide with. A caller who wrote conflictKeys: ['email'] on a table whose only unique index is on tax_id gets a silent merge against tax_id on MySQL, where SQLite and Postgres both refuse. That is a worse failure than the one #8445 and #8567 fixed: those were legible-versus-illegible errors, this is a wrong write with no error at all.
⚠️The paragraph above is an inference from the compiled SQL, not an observation. Nobody has watched a MySQL server do it. #8445's dispatch ruled that describing dialect behaviour from memory or inference is not acceptable evidence, and #8567's dev correctly declined to file this card on that basis — the container has no mysqld/mariadbd and its docker daemon is unreachable.
The measurement channel exists — in CI, not on a dev box
That premise is resolvable. CI already provisions a live MySQL for the conformance job:
and packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts already reads OS_TEST_MYSQL_URL, reporting an un-run cell rather than passing vacuously. sql-driver-upsert-conflict-target-dialects.test.ts (landing with #8567) currently declares its MySQL cell un-run for exactly this reason — the file runs under Test Core, which has no such service.
What this card is, in order
Measure. On a runner that has the MySQL service: create an object whose unique index is on a column other than the one named in conflictKeys, upsert with the mismatched key, and record what actually happens — silent merge on the other key, an error, or something else. ⛔ Do not write the answer into a test before observing it.
Then decide, and only then. Options range from leaving it (documented, dialect-honest) through refusing pre-flight in driver-sql when the dialect cannot honour a named target — the latter changes the accept set on MySQL, so it is a minor and wants its own argument, not a rider.
Either way the MySQL cell should stop being structurally un-runnable: either wire this file's matrix into a job that has the servers, or state why it belongs where it is.
Related: #8567 (where the compiled SQL was read), #8445 / #8413 (the two faces that refuse this condition), #8590 (the other finding out of #8567), #5240 (one condition, one wording).
Triage: lands in packages/drivers/driver-sql (the live-dialect matrix + sql-driver-upsert-conflict-target-dialects.test.ts, on main since PR #8591); rationale: well-scoped measurement card with a named channel (CI's OS_TEST_MYSQL_URL service) — queued.
Native type set to Task, not Bug: the silent-merge-on-the-wrong-key hazard is the card's own flagged inference (⚠️ "not an observation"), and the first deliverable is the measurement. If step 1 confirms the wrong write, re-type Bug on the spot.
Scope guard for dispatch: step 1 (measure) + step 3 (make the MySQL cell runnable or justify its home) only. Any step-2 change to the MySQL accept set (e.g. a pre-flight refusal) is a minor with its own argument — comes back as its own card, not a rider.
File surface: packages/drivers/driver-sql/src/** (the dialects test + live-dialect-matrix.testkit.ts) and .github/workflows/ci.yml if step 3 needs it. ⛔ Not packages/spec, ⛔ not docs/adr/**. Serial constraints: none — triage re-checked, #8567 merged 01:10Z.
⚠️ I have to correct my own card before dispatching it
I wrote "the file runs under Test Core, which has no such service."That is wrong, and the correction makes this card much sharper. Verified on origin/main just now — the conformance job's step is literally:
- name: Run driver-sql suite against both live serversenv:
OS_TEST_POSTGRES_URL: postgres://…OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformanceOS_EXPECT_LIVE_DIALECT_MATRIX: '1'run: … pnpm --filter @objectstack/driver-sql test
So sql-driver-upsert-conflict-target-dialects.test.tsalready runs with a live MySQL 8.0 attached, in CI, today. The server is not missing — it is present and unused.
What that exposes, and it is worse than the gap I filed
The file's MySQL cell is guarded one way only (:323-326, the last lines of the file):
constMYSQL_CELL=DIALECT_CELLS.find((c)=>c.id==='mysql')!;if(!MYSQL_CELL.available){declareUnprovisionedCell(MYSQL_CELL,'unbacked conflict-target refusal (behaviour never observed)');}
There is no else. So:
job
OS_TEST_MYSQL_URL
what the MySQL half does
Test Core
absent
declares itself un-run — visible
Temporal Conformance (live PG + MySQL)
present
declares nothing, measures nothing — invisible
The declaration disappears exactly when the capability to measure appears. That is the inverse of the usual vacuous-green: not a cell that passes without running, but a gap that stops announcing itself on the one runner that could close it. OS_EXPECT_LIVE_DIALECT_MATRIX: '1' exists specifically to turn a missing-URL skip into a named red, and this cell slips past it by not being a skip at all.
None of this is a criticism of #8591 — its declaration is correct for the container it was written in, and the dev could not have measured MySQL there. It is the premise of this card that was incomplete, and it was mine.
What the dispatch therefore asks for
Step 1 is now immediately actionable rather than blocked on someone with a server: add the measuring branch and let CI run it.⚠️ The dev must still not write the expected answer first — the pin records what the server did, and if MySQK merges on the wrong key, that is a wrong write with no error and the card re-types to Bug on the spot (triage's instruction).
Scope guard adopted from triage verbatim: steps 1 and 3 only. Any change to MySQL's accept set — a pre-flight refusal when the dialect cannot honour a named target — is a minor that comes back as its own card, not a rider on a measurement.
Model: opus, matching triage. Not fable tier: measuring and reporting does not move any accept/reject set, and nothing here widens a spec surface.
{
"issue": 8592,
"status": "done",
"branch": "claude/issue-8592-mysql-conflict-target-behaviour",
"pr": "https://github.com/objectstack-ai/objectstack/pull/8624",
"premise_still_valid": true,
"summary": "OBSERVED on a live MySQL 8.0.46 raised in this container (Ubuntu noble mysql-server via apt, mysqld --daemonize, default_time_zone='+08:00'), driven through the same knex + mysql2 path upsert takes — no inference. Table: `email` named in conflictKeys with NO unique index, `tax_id` carrying the only unique index. Verbatim: seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> RESOLVED, rows=[{id:'VBjOQwQp3uTtewte', email:'a@b.com', tax_id:'T-1'}]. Then upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> RESOLVED, rows=[{id:'RnSaXzGO69OKkP_D', email:'other@b.com', tax_id:'T-1'}] — ONE row, merged on `tax_id` (the key the caller never named) across two DIFFERENT `email` values, and the surviving row's PRIMARY KEY changed. Then, from a clean table, seed + upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> RESOLVED, TWO rows both email='a@b.com'. Reproduced across two independent runs. So four facts: (1) no refusal, where SQLite and Postgres both answer VALIDATION_ERROR/400 for the identical call; (2) a silent merge on the wrong key — the card's inference, now confirmed; (3) the merged row's PRIMARY KEY is REPLACED, because `id` sits in the merge set (`id = values(id)`) — the inference did NOT contain this and it is the sharpest edge, since every external reference to the old id dangles with no error; (4) the named key is not honoured at all, yielding duplicates. MySQL fails in both directions at once. The dispatch's premise correction was right and is the second half of the fix: the file's MySQL cell was guarded one way only, so on the one CI job with a live MySQL attached it declared nothing and measured nothing. Fixed structurally — declareDialectCell(cell, matrix, measure) in live-dialect-matrix.testkit.ts is a TOTAL function, so the half-pair form no longer typechecks; the other six consumers were checked and are already two-way. New pins are marked loudly as characterizations of a DEFECT, to be rewritten (not relaxed) when #8621 lands.",
"tests": "Both live servers raised locally, so verification ran in CI's exact shape rather than a subset — TZ=America/New_York, live PG 16.13 at Asia/Shanghai, live MySQL 8.0.46 at +08:00, OS_EXPECT_LIVE_DIALECT_MATRIX=1. Full package suite: `pnpm --filter @objectstack/driver-sql test` -> 'Test Files 98 passed (98) / Tests 2011 passed (2011)'. Target file across all three cells -> '19 passed (19)' (6 sqlite + 6 live postgres + 2 compile pins + 5 new live mysql). `tsc --noEmit` clean. Un-provisioned paths checked BOTH ways: with no URLs the MySQL cell reports a named skip ('is provisioned — set OS_TEST_MYSQL_URL to run this cell of the D-A3 driver axis'); with OS_EXPECT_LIVE_DIALECT_MATRIX=1 and URLs dropped it is a named red (2 failed | 8 passed). REVERSE VERIFICATION, direction predicted before each leg, fix committed first: leg 1a — dropping the `measure` argument predicted RED AT TYPECHECK not runtime; observed exactly 'error TS2554: Expected 3 arguments, but got 2.' Matched. Leg 1b — restoring the ORIGINAL one-way block with a live MySQL attached predicted the gap returns SILENTLY and green; observed 19 passed -> 14 passed, still green, tsc exit 0, the five MySQL tests vanished with no signal. Matched, and this is the defect reproduced. Honest bound: leg 1b also shows the combinator does NOT catch a wholesale revert to the one-way form (tsc exit 0) — it makes the half-pair unrepresentable, not the deletion. Positive control predicted unchanged and was: SQLite and Postgres cells pass identically, sweep code untouched. Restored byte-identical after both legs (git status clean, git diff --stat HEAD empty). Gates re-derived against actual changed paths via scripts/pm/dispatch-gates.mjs, which surfaced four families the prompt did not name — check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage — all run and green, plus check:nul-bytes OK.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #8621: the step-2 card triage carved out — MySQL silently merges on a unique key the caller never named and rewrites the merged row's primary key; carries the measurement as its evidence and recommends the pre-flight refusal, since it moves MySQL's accept set and is a minor with its own argument",
"filed as #8622: an upsert merging on ANY non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite AND MySQL with a properly backed unique conflict target, so it is not MySQL-specific and not about conflict-target validity. Cause: `id` is minted per call and is absent from insertOnlyUpsertColumns, so it travels in the merge set; a no-op only on the default id-keyed path, which is why it stayed invisible. Same argument #7011 accepted for auto_number"
]
}
Labels read back (REST, after bots settled): skip-changesetandtests. Changeset decision: skip-changeset, no changeset file — tests plus a test-only helper (live-dialect-matrix.testkit.ts is not exported from index.ts, verified), no runtime path changed.
CI convergence not waited on, per the standing contract. Gate status at hand-off is in_progress; the local run above was CI's exact command with both live servers attached, which is the strongest signal available before the runner reports.
Local servers I started (mysqld, postgres) and the worktree have been torn down.
Closing as completed. PR #8624 merged as 424bbd48b — closing manually because it is Part of, not Fixes, and that was deliberate.
Steps 1 and 3 are the dispatched scope in full. Step 2 (deciding whether driver-sql should refuse pre-flight on a dialect that cannot honour a named conflict target) was carved out by triage before this card was claimed, on the grounds that it moves MySQL's accept set and is a minor needing its own argument. It is now #8621, carrying this card's measurement as its evidence.
The structural fix.declareDialectCell(cell, matrix, measure) is total — measure is required, so a cell that neither measures nor declares itself un-run stops typechecking. The gap this card was filed about was not "MySQL is untested"; it was that the un-run declaration disappeared on the one runner with a live server attached, and OS_EXPECT_LIVE_DIALECT_MATRIX could not catch it because a cell emitting no suite at all is not a skip.
⚠️ For whoever picks up #8621: the pins added here are characterizations of a defect, marked as such in the file. When the refusal lands they go red, and they must be rewritten to the refusal, not relaxed to keep them green.
⚠️ And a correction worth carrying forward, since it cost this card a hold: I gated it on "the container has no MySQL server." The binaries genuinely were not installed — but I let that stand in for not measurable, and the dev simply installed mysql-server and got the answer. "Not currently installed" is not a measurement gate.
pm:blocking (cache, derived from the Blocked-by index this round): open dependents #8621 and #8622 — both wait on this card's landing tests in sql-driver-upsert-conflict-target-dialects.test.ts. Unlock scan releases them when this closes.
Activity
hotlong commentedon Aug 14, 2026
Triage: lands in
packages/drivers/driver-sql(the live-dialect matrix +sql-driver-upsert-conflict-target-dialects.test.ts, onmainsince PR #8591); rationale: well-scoped measurement card with a named channel (CI'sOS_TEST_MYSQL_URLservice) — queued.main. Re-checked at triage time.minorwith its own argument — comes back as its own card, not a rider.mode:subagent, opus.Generated by Claude Code
os-zhuang commentedon Aug 14, 2026
Claim: drivers seat, session
session_01VoxQqG5FiUHZKCST7KDoZC, branchclaude/issue-8592-mysql-conflict-target-behaviour.File surface:
packages/drivers/driver-sql/src/**(the dialects test +live-dialect-matrix.testkit.ts) and.github/workflows/ci.ymlif step 3 needs it. ⛔ Notpackages/spec, ⛔ notdocs/adr/**. Serial constraints: none — triage re-checked, #8567 merged 01:10Z.I wrote "the file runs under Test Core, which has no such service." That is wrong, and the correction makes this card much sharper. Verified on
origin/mainjust now — the conformance job's step is literally:So
sql-driver-upsert-conflict-target-dialects.test.tsalready runs with a live MySQL 8.0 attached, in CI, today. The server is not missing — it is present and unused.What that exposes, and it is worse than the gap I filed
The file's MySQL cell is guarded one way only (
:323-326, the last lines of the file):There is no
else. So:OS_TEST_MYSQL_URLThe declaration disappears exactly when the capability to measure appears. That is the inverse of the usual vacuous-green: not a cell that passes without running, but a gap that stops announcing itself on the one runner that could close it.
OS_EXPECT_LIVE_DIALECT_MATRIX: '1'exists specifically to turn a missing-URL skip into a named red, and this cell slips past it by not being a skip at all.None of this is a criticism of #8591 — its declaration is correct for the container it was written in, and the dev could not have measured MySQL there. It is the premise of this card that was incomplete, and it was mine.
What the dispatch therefore asks for
Step 1 is now immediately actionable rather than blocked on someone with a server: add the measuring branch and let CI run it.⚠️ The dev must still not write the expected answer first — the pin records what the server did, and if MySQK merges on the wrong key, that is a wrong write with no error and the card re-types to Bug on the spot (triage's instruction).
Scope guard adopted from triage verbatim: steps 1 and 3 only. Any change to MySQL's accept set — a pre-flight refusal when the dialect cannot honour a named target — is a
minorthat comes back as its own card, not a rider on a measurement.Model:
opus, matching triage. Not fable tier: measuring and reporting does not move any accept/reject set, and nothing here widens a spec surface.Generated by Claude Code
os-zhuang commentedon Aug 14, 2026
{ "issue": 8592, "status": "done", "branch": "claude/issue-8592-mysql-conflict-target-behaviour", "pr": "https://github.com/objectstack-ai/objectstack/pull/8624", "premise_still_valid": true, "summary": "OBSERVED on a live MySQL 8.0.46 raised in this container (Ubuntu noble mysql-server via apt, mysqld --daemonize, default_time_zone='+08:00'), driven through the same knex + mysql2 path upsert takes — no inference. Table: `email` named in conflictKeys with NO unique index, `tax_id` carrying the only unique index. Verbatim: seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> RESOLVED, rows=[{id:'VBjOQwQp3uTtewte', email:'a@b.com', tax_id:'T-1'}]. Then upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> RESOLVED, rows=[{id:'RnSaXzGO69OKkP_D', email:'other@b.com', tax_id:'T-1'}] — ONE row, merged on `tax_id` (the key the caller never named) across two DIFFERENT `email` values, and the surviving row's PRIMARY KEY changed. Then, from a clean table, seed + upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> RESOLVED, TWO rows both email='a@b.com'. Reproduced across two independent runs. So four facts: (1) no refusal, where SQLite and Postgres both answer VALIDATION_ERROR/400 for the identical call; (2) a silent merge on the wrong key — the card's inference, now confirmed; (3) the merged row's PRIMARY KEY is REPLACED, because `id` sits in the merge set (`id = values(id)`) — the inference did NOT contain this and it is the sharpest edge, since every external reference to the old id dangles with no error; (4) the named key is not honoured at all, yielding duplicates. MySQL fails in both directions at once. The dispatch's premise correction was right and is the second half of the fix: the file's MySQL cell was guarded one way only, so on the one CI job with a live MySQL attached it declared nothing and measured nothing. Fixed structurally — declareDialectCell(cell, matrix, measure) in live-dialect-matrix.testkit.ts is a TOTAL function, so the half-pair form no longer typechecks; the other six consumers were checked and are already two-way. New pins are marked loudly as characterizations of a DEFECT, to be rewritten (not relaxed) when #8621 lands.", "tests": "Both live servers raised locally, so verification ran in CI's exact shape rather than a subset — TZ=America/New_York, live PG 16.13 at Asia/Shanghai, live MySQL 8.0.46 at +08:00, OS_EXPECT_LIVE_DIALECT_MATRIX=1. Full package suite: `pnpm --filter @objectstack/driver-sql test` -> 'Test Files 98 passed (98) / Tests 2011 passed (2011)'. Target file across all three cells -> '19 passed (19)' (6 sqlite + 6 live postgres + 2 compile pins + 5 new live mysql). `tsc --noEmit` clean. Un-provisioned paths checked BOTH ways: with no URLs the MySQL cell reports a named skip ('is provisioned — set OS_TEST_MYSQL_URL to run this cell of the D-A3 driver axis'); with OS_EXPECT_LIVE_DIALECT_MATRIX=1 and URLs dropped it is a named red (2 failed | 8 passed). REVERSE VERIFICATION, direction predicted before each leg, fix committed first: leg 1a — dropping the `measure` argument predicted RED AT TYPECHECK not runtime; observed exactly 'error TS2554: Expected 3 arguments, but got 2.' Matched. Leg 1b — restoring the ORIGINAL one-way block with a live MySQL attached predicted the gap returns SILENTLY and green; observed 19 passed -> 14 passed, still green, tsc exit 0, the five MySQL tests vanished with no signal. Matched, and this is the defect reproduced. Honest bound: leg 1b also shows the combinator does NOT catch a wholesale revert to the one-way form (tsc exit 0) — it makes the half-pair unrepresentable, not the deletion. Positive control predicted unchanged and was: SQLite and Postgres cells pass identically, sweep code untouched. Restored byte-identical after both legs (git status clean, git diff --stat HEAD empty). Gates re-derived against actual changed paths via scripts/pm/dispatch-gates.mjs, which surfaced four families the prompt did not name — check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage — all run and green, plus check:nul-bytes OK.", "open_questions": [], "out_of_scope_findings": [ "filed as #8621: the step-2 card triage carved out — MySQL silently merges on a unique key the caller never named and rewrites the merged row's primary key; carries the measurement as its evidence and recommends the pre-flight refusal, since it moves MySQL's accept set and is a minor with its own argument", "filed as #8622: an upsert merging on ANY non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite AND MySQL with a properly backed unique conflict target, so it is not MySQL-specific and not about conflict-target validity. Cause: `id` is minted per call and is absent from insertOnlyUpsertColumns, so it travels in the merge set; a no-op only on the default id-keyed path, which is why it stayed invisible. Same argument #7011 accepted for auto_number" ] }Notes for the PM, outside the JSON
Part of #8592, notFixes. The card body's step 2 is not done here — triage excluded it from dispatch, and it is drivers(sql): MySQL silently merges an upsert on a unique key the caller never named, and rewrites the merged row's primary key — measured, and it is a wrong write with no error #8621. Steps 1 and 3 are complete. If the re-scope is authoritative, drivers(sql): what MySQL does with a conflict target it cannot honour is unmeasured — knex compiles the target away, so the refusal cannot arise there #8592 can be closed on merge; deliberately left as your call rather than closing it from the PR.skip-changesetandtests. Changeset decision:skip-changeset, no changeset file — tests plus a test-only helper (live-dialect-matrix.testkit.tsis not exported fromindex.ts, verified), no runtime path changed.in_progress; the local run above was CI's exact command with both live servers attached, which is the strongest signal available before the runner reports.Generated by Claude Code
os-zhuang commentedon Aug 14, 2026
Closing as completed. PR #8624 merged as
424bbd48b— closing manually because it isPart of, notFixes, and that was deliberate.Steps 1 and 3 are the dispatched scope in full. Step 2 (deciding whether
driver-sqlshould refuse pre-flight on a dialect that cannot honour a named conflict target) was carved out by triage before this card was claimed, on the grounds that it moves MySQL's accept set and is aminorneeding its own argument. It is now #8621, carrying this card's measurement as its evidence.What this card actually produced:
mysql2pathupserttakes. MySQL does not refuse; it merges on a unique key the caller never named, and it does not honour the key it was given. My filed inference was right about the merge and missed the sharper half: the surviving row's primary key is replaced. That is now drivers(sql): an upsert that merges on a non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite and MySQL alike #8622, and it reproduces on SQLite too, so it was never a MySQL story.declareDialectCell(cell, matrix, measure)is total —measureis required, so a cell that neither measures nor declares itself un-run stops typechecking. The gap this card was filed about was not "MySQL is untested"; it was that the un-run declaration disappeared on the one runner with a live server attached, andOS_EXPECT_LIVE_DIALECT_MATRIXcould not catch it because a cell emitting no suite at all is not a skip.mysql-serverand got the answer. "Not currently installed" is not a measurement gate.Generated by Claude Code
hotlong commentedon Aug 14, 2026
pm:blocking(cache, derived from the Blocked-by index this round): open dependents #8621 and #8622 — both wait on this card's landing tests insql-driver-upsert-conflict-target-dialects.test.ts. Unlock scan releases them when this closes.Generated by Claude Code
test(driver-sql): measure what MySQL does with a conflict target it c…
fix(driver-sql): treat `id` as insert-only on the upsert merge path (o…
fix(spec): os migrate meta guidance for the driver-*, kernel-* and sy…