fix(driver-sql): a merge-path upsert no longer rewrites an existing row's autonumber (#7011) - #7059
Conversation
…ow's autonumber (#7011) An autonumber is an immutable business identifier once assigned (triage ruling on the card). fillAutoNumberFields reserves a fresh number before the statement knows whether it will insert or merge, and the autonumber column sat in mergeColumns — so every ON CONFLICT ... DO UPDATE wrote the freshly reserved number over the existing row's, measured on a healthy counter: create -> CASE-00001, two upserts of the same id -> CASE-00002 then CASE-00003, one row throughout. auto_number columns are now excluded from the merge column list, exactly like created_at (insert-only facts about the row's birth). The exclusion is unconditional — an explicit payload value does not renumber on merge either; update() remains the deliberate renumbering path. Insert-path upserts still assign fresh numbers; every non-autonumber column (including updated_at) merges as before. Out of scope, deliberately (#6943's reseed family): the reservation still happens before insert-vs-merge is known, so a merge-only upsert still consumes one sequence value per call — a permanent gap now, no longer a rewrite (measured post-fix: last_value 1 -> 2 -> 3, next fresh row gets CASE-00004). Faces: SqliteWasmDriver inherits upsert; TursoDriver local routes its override to super — both pinned by their own tests. Turso remote never enters fillAutoNumberFields (neither defect nor fix). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc
|
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:
|
|
PM step-7 acceptance review — PASS (session_01LGRN2cSRfggfX9B2L83bQc, maintainer-expedited v17 card). Verified independently against head Generated by Claude Code |
Fixes #7011.
The ruling this PR implements (triage, 2026-08-09, quoted per the dispatch)
Premise re-verified on the branch base (
main@3fc2e48, healthy counter, one row)The new pin file was run before the fix, and went red with exactly the filing's values:
One row throughout; its
case_numberwas rewritten twice. Cause, as filed:fillAutoNumberFieldsreserves a number before the statement knows whether it will insert or merge, and the autonumber column sat inmergeColumns, so theON CONFLICT … DO UPDATEbranch wrote the fresh reservation over the row's existing number.The fix
SqlDriver.upsert()now derives its merge column list through a newinsertOnlyUpsertColumns(object)helper:created_at(the pre-existing exclusion, byte-identical behavior) plus everyauto_numbercolumn of the object, resolved under the same object-name/table-name lookupfillAutoNumberFieldsuses and mapped to physical column names (external objects can remap logical fields viaexternal.columnMap, and the merge list is derived from theapplyWriteColumnMap-processed row).The exclusion is unconditional: an explicit autonumber value in the upsert payload does not renumber an existing row on the merge branch either —
update()writes what it is given and remains the deliberate renumbering path. Insert-path upserts still assign fresh numbers, and every non-autonumber column (includingupdated_at) merges exactly as before.Consumer check (the STOP condition) — no consumer relies on merge-overwrite
Every driver-level
upsertcaller in the monorepo was enumerated:packages/objectql/src/lifecycle/lifecycle-service.ts:1110,cold.upsert(object, row, ['id'])) — copies full hot rows carrying their autonumber values explicitly. The merge branch only re-fires when an interrupted sweep re-copies the same unchanged row (archived rows are hot-deleted after copy), so the excluded column's payload value equals the stored value and the exclusion is observably a no-op there.packages/runtime/src/sandbox/body-runner.ts:276guards ontypeof ql.upsert === 'function'—ObjectQLEngineexposes noupsertmethod, so this path routes toql.inserttoday.writeMode: 'upsert'is find-then-create-or-update at the engine level (import-runner.tsfindExisting) and never calls driverupsert.No fixture outside the three driver packages combines driver
upsertwith autonumber (swept by grep over every@objectstack/driver-sql-importing test).Faces
SqlDriverSqliteWasmDriverupsertnot overridden)TursoDriverlocal/replicaupsert, routes tosuperTursoDriverremoteRemoteTransport.upsertnever entersfillAutoNumberFields(#6999's table; unchanged)driver-memory/driver-mongodbare #5499-frozen and untouched — neither declaressupports.autonumber; both use the engine fallback (#6806's surface), per the card's own boundary note.Out of scope, measured for #6943's card (the pre-burn reading the dispatch asked for)
Post-fix, on a healthy counter: the row keeps
CASE-00001through both merge-upserts, butlast_valuestill walks 1 → 2 → 3 — the reservation still happens before insert-vs-merge is known, so a merge-only upsert still consumes one sequence value per call. The next insert-path row getsCASE-00004, i.e.CASE-00002/CASE-00003are now permanent gaps rather than rewrites. Deferring the reservation is #6943-family work; no test in this PR assertslast_valueon the merge path, so that future fix cannot turn this PR's pins red.Existing rows already renumbered by past merges cannot be restored from the driver side (as the filing noted).
Reverse verification (direction predicted before running)
Predicted: restoring the deleted limb (autonumber exclusion off) turns exactly the merge-path pins red, with the filing's own values; insert-path and non-autonumber-merge cases stay green. Measured: driver-sql pin file 4 failed / 1 passed (
expected 'CASE-00002' to be 'CASE-00001','TKT-0002'vs'TKT-0001'), sqlite-wasm file 1 failed / 2 passed (only the new #7011 pin), turso file 1 failed / 3 passed (only the new LOCAL #7011 pin). Fix restored: 5/3/4 all green.Tests
pnpm --filter @objectstack/driver-sql test— 80 files passed, 4 skipped (1147 tests passed, 48 skipped)pnpm --filter @objectstack/driver-sqlite-wasm --filter @objectstack/driver-turso test— 23 files / 308 tests and 32 files / 912 tests, all passedtsc --noEmit(typecheck) green on all three packages; ESLint clean on the four touched files@objectstack/driver-sqlpatch (user-visible behavior fix)🤖 Generated with Claude Code
https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc
Generated by Claude Code