Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): route updateMany's payload through formatInput / applyWriteColumnMap - #11302
Conversation
…lyWriteColumnMap (#11223) updateMany() was the only write door in sql-driver.ts that passed the caller's `data` straight to `builder.update(data)`. Every other one — create, update, bulkCreate, upsert, rotatedUpdateById — applies `applyWriteColumnMap(object, formatInput(object, data))` first, and the WHERE side of the very same bulk statement was already translated by applyFilters. Measured on SQLite, live PostgreSQL 16.13 and live MySQL 8.0.46: - json / Field.multiple values were REFUSED — 22P02 on Postgres, a bind refusal on SQLite, and a SET-list syntax error on MySQL where the array expanded into the statement. - A federated external.columnMap object emitted a SET naming the local field beside a WHERE naming the physical column, in one statement: update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'. - Temporal values were stored verbatim and silently. On SQLite that is the pre-#3912 zone-naive form, written into a column canonicalDatetimeFields had certified and therefore stopped repairing on read; a range filter over the written day could no longer see the row. On live Postgres the same literal resolved in the SERVER's timezone: a silent 8-hour instant shift. Field.date and Field.time were affected the same way. The stamping decision now reads the formatted payload, matching update() and rotatedUpdateById; #11176's stamp is unchanged and still applied afterwards as the literal post-map column name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0058d37d67e73e775a192f94bc462cd3552d1c31 && git checkout 0058d37d67e73e775a192f94bc462cd3552d1c31
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9 7ffd293977eb89e9571ff307ff02845896df92b3 && git checkout -B drift-repro 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9 && git merge --no-ff 7ffd293977eb89e9571ff307ff02845896df92b3
node scripts/docs-audit/affected-docs.mjs --json 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11223
updateMany()was the only write door insql-driver.tsthat passed the caller'sdatastraight tobuilder.update(data). Every other one —create,update,bulkCreate,upsert,rotatedUpdateById— appliesapplyWriteColumnMap(object, formatInput(object, data))first, and the WHERE side of the same bulk statement was already translated byapplyFilters.One hunk, in
updateMany's docblock and body.Baseline, measured live before the fix
SQLite, live PostgreSQL 16.13 (
Asia/Shanghaiserver) and live MySQL 8.0.46 (+08:00), process atTZ=America/New_York, through the driver's owninitObjects.Defect 1 — json /
Field.multiplerefused, all three dialects. The card measured PG and SQLite; MySQL fails too, and differently:On MySQL the array expands into the SET list (
`tags` = 'y', 'z') — a syntax error, not a bind error.Defect 2 — federated
columnMapbulk update, reproduced exactly as the card records it:Defect 3 — silent, and NOT SQLite-only. The same naive input through both doors:
formatInput's own ADR-0053 note describes ("measured at 8 hours off on anAsia/Shanghaiserver"). SQLite's is a storage-form regression; Postgres's is a wrong instant. MySQL is accidentally correct only because the driver pins the mysql2 session to UTC (#3942).Defect 3's unrepairability, measured end to end (SQLite).
initObjectscertifies the fresh column incanonicalDatetimeFields, so the read-side repair is already dropped —needsLegacyDatetimeRepair('probe_tmp','when') = false. With both rows written to the same calendar day through the two doors, a range filter over that day returned:The bulk-written row is on disk carrying the right day and invisible to the query. Nothing downstream repairs it.
Two things the card left open, now measured:
dateandtime("presumably affected the same way; onlydatetimewas measured") — affected, with a dialect twist: stored verbatim on SQLite (a full ISO string in a date-only and a time-of-day column), and refused outright on live PG (invalid input syntax for type time: "2026-05-06T07:08:09.000Z") and MySQL (Incorrect date value). Silent on SQLite, loud on the live dialects.NOW()token. Unfixed, SQLite stored the four-character string"NOW()"into a datetime column and MySQL refused it (Incorrect datetime value: 'NOW()'), whileupdate()resolved it on all three. Postgres's own parser happens to accept'NOW()'. Now resolves on this door like every other one.Consumer sweep — nothing depends on the raw pass-through
The card and triage both asked for this before assuming the reroute is free.
driver.updateManyispackages/objectql/src/engine.ts:10265(the predicate-write branch), which passeshookContext.input.data— the same object the by-id branch atengine.ts:10075passes todriver.update, a door that has always coerced it. One caller, one payload shape, two doors that disagreed.TursoDriver.updateMany(driver-turso) delegates tosuper.updateManylocally and applies its owntoRemoteWriteForms(object, data)remotely — the same helper its other write paths use.MemoryDriver.updateManyappliestoStorageForms(object, …), the identical helper itscreateandupdateuse.So every sibling implementation of this door already coerces its payload;
SqlDriver's local path was the lone exception.Tier (PM assumption 2): restoration, not widening
The sibling-implementation comparison settles it the way #11176's dev settled the analogous question.
driver-memoryanddriver-tursoboth apply their own write coercion onupdateManyspecifically, and the single engine caller hands this door the same payload it handsupdate(). The accept set being restored is the one the contract already declares and every other implementation already honours —updateManypredates the coercion registries, as the card reads it. Literally it does turn erroring calls into succeeding ones, so the reclassification remains the PM's; the measurement points at restoration.On-hold neighbours: neither restart condition touched
The diff is one hunk inside
updateMany. Counted over the changed lines, with a positive control proving the pattern finds them in the file (10 / 7 / 2 occurrences respectively):sqliteCanonicalDatetimeSql(#6009)backfillCanonicalDatetimes(#6009)insertOnlyUpsertColumns(#8740)#6009 is not woken. Defect 3 sits in canonical-datetime territory, but the fix restores the
canonicalDatetimeFieldsinvariant from the write side rather than changing how the repair or the backfill works. #8740 is not woken: no new member, no fourth dialect, no conflict-target DEFAULT.Tests
sql-driver-11223-updatemany-write-coercion.test.ts— six sections across SQLite + live PG + live MySQL viadeclareDialectCell, so an unprovisioned dialect is reported rather than omitted.§1 json/
multiple· §2 datetime/date/time from the same naive input through both doors, asserting the instant (so the PG cell fails on the shift, not the spelling) · §3 the certified-canonical column stays findable · §4 the federated SET clause · §5 theNOW()token · §6 the guard that #11176's stamping decisions are unchanged.Reverse verification — predicted, then measured, and the two differed. Predicted §1–§5 red, §6 green. Restoring
main'supdateManybody gave 13 red / 5 green, and the survivors are the informative half:'NOW()'. A §5 written on the PG cell alone would have measured nothing.jsonfield, which defect 1 refuses outright on the unfixed driver, so it could never reach its own assertion. It now patches a plainstringfield and is green on both trees, which is what a guard on an unchanged decision should be.The mutation and the restore were each proven on disk (the fix line absent/present,
main's line present/absent, a 4754-byte diff then 0) before any result was read. The mutation script carries atrap … EXIT INT TERMrestore.Verification, at
7ffd293Everything below ran on the final commit; exit status captured before any pipe, and each verdict quoted from the gate's own output.
pnpm --filter @objectstack/driver-sql typecheck→ EXIT=0 (tsc --noEmitechoed, so not a zero-match)TZ=America/New_York+ live PG + live MySQL +OS_EXPECT_LIVE_DIALECT_MATRIX=1→Test Files 120 passed (120) · Tests 2467 passed (2467)Tests 18 passed (18)pnpm lint(eslint . --no-inline-config, whole repo) → EXIT=0. Run in full; no narrowing declared.node scripts/pm/dispatch-gates.mjs(no paths — it derives its own change set) → 13 path-matched + 6 convention-triggered families, all green, includingcheck:driver-conformance(OK — 45 covered cell(s), 0 in the DEBT ledger),check:engine-double-contract(OK — 384 pinned),check:where-matcher(282 matcher(s) … none new),check:type-check-debt(OK — 33 ledger entr(ies) re-measured … none above its recorded number, on the built workspace closure),check:published-files,check:test-source-alias,check:slot-lookup,check:query-options-erasure,check:cross-package-test-inputs,check:changeset-no-major,check:empty-changeset.pnpm check:adr-anchorsby hand, sincedispatch-gates.mjsdoes not select it →check-adr-anchors: OK (52 anchored file(s), every governing ADR still referenced; … 27499 citation(s) across 3387 file(s) resolve)pnpm check:nul-bytes→OK (scanned 6335 text file(s) … no raw ASCII control bytes), plus a hand scan of the three touched files with a positive control.Zero-hit discipline: every "nothing found" above is paired with a live control — the duplicate-branch sweep against
issue-9167(2 branches), the on-hold symbol counts against their in-file occurrences, and the control-byte pattern against a real injected control byte.Changed-file surface (PM assumption 1)
Declared:
packages/drivers/driver-sql/src/sql-driver.tsonly. Landed as declared for source, plus the new test file and the changeset:packages/drivers/driver-sql/src/sql-driver.tspackages/drivers/driver-sql/src/sql-driver-11223-updatemany-write-coercion.test.ts(new).changeset/updatemany-write-coercion.md(new)No other source file is touched, so the serialization hold on #11224 and #11201 can be released on this list.
Generated by Claude Code