Skip to content

fix(driver-sql): route updateMany's payload through formatInput / applyWriteColumnMap - #11302

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11223-updatemany-format-input
Aug 23, 2026
Merged

fix(driver-sql): route updateMany's payload through formatInput / applyWriteColumnMap#11302
os-zhuang merged 1 commit into
mainfrom
claude/issue-11223-updatemany-format-input

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#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 same bulk statement was already translated by applyFilters.

One hunk, in updateMany's docblock and body.

Baseline, measured live before the fix

SQLite, live PostgreSQL 16.13 (Asia/Shanghai server) and live MySQL 8.0.46 (+08:00), process at TZ=America/New_York, through the driver's own initObjects.

Defect 1 — json / Field.multiple refused, all three dialects. The card measured PG and SQLite; MySQL fails too, and differently:

update() -> {"payload":{"a":2},"tags":["y","z"]} <- correct, same run
updateMany() -> THREW, each dialect in its own voice:
pg update "probe_json" set "payload" = $1, "tags" = $2, … - invalid input syntax for type json
sqlite … - SQLite3 can only bind numbers, strings, bigints, buffers, and null
mysql update `probe_json` set `payload` = {"a":2}, `tags` = 'y', 'z', … - You have an error in your SQL syntax

On MySQL the array expands into the SET list (`tags` = 'y', 'z') — a syntax error, not a bind error.

Defect 2 — federated columnMap bulk update, reproduced exactly as the card records it:

updateMany() THREW: update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'
- no such column: name

Defect 3 — silent, and NOT SQLite-only. The same naive input through both doors:

 input '2026-05-06 07:08:09' (denotes 2026-05-06T07:08:09.000Z)
sqlite update() when = "2026-05-06T07:08:09.000Z"
updateMany() when = "2026-05-06 07:08:09" <- pre-#3912 storage form
pg update() when = Date(2026-05-06T07:08:09.000Z)
updateMany() when = Date(2026-05-05T23:08:09.000Z) <- -8h, silent instant shift
mysql updateMany() when = Date(2026-05-06T07:08:09.000Z) <- accidentally correct

⚠️The card scoped defect 3 to SQLite ("PG/MySQL production deployments are not implicated"). Measured, live Postgres is implicated and is worse: the zone-naive literal is resolved in the server's timezone rather than UTC, so the stored instant moves by the server offset — exactly the hazard formatInput's own ADR-0053 note describes ("measured at 8 hours off on an Asia/Shanghai server"). 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). initObjects certifies the fresh column in canonicalDatetimeFields, 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:

rows found = ["a"] [a = update(), b = updateMany()]

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:

  • date and time ("presumably affected the same way; only datetime was 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.
  • The NOW() token. Unfixed, SQLite stored the four-character string "NOW()" into a datetime column and MySQL refused it (Incorrect datetime value: 'NOW()'), while update() 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.

  • The only production caller of driver.updateMany is packages/objectql/src/engine.ts:10265 (the predicate-write branch), which passes hookContext.input.data — the same object the by-id branch at engine.ts:10075 passes to driver.update, a door that has always coerced it. One caller, one payload shape, two doors that disagreed.
  • TursoDriver.updateMany (driver-turso) delegates to super.updateMany locally and applies its own toRemoteWriteForms(object, data) remotely — the same helper its other write paths use.
  • MemoryDriver.updateMany applies toStorageForms(object, …), the identical helper its create and update use.

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-memory and driver-turso both apply their own write coercion on updateMany specifically, and the single engine caller hands this door the same payload it hands update(). The accept set being restored is the one the contract already declares and every other implementation already honours — updateMany predates 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):

symbolchanged lines
sqliteCanonicalDatetimeSql (#6009)0
backfillCanonicalDatetimes (#6009)0
insertOnlyUpsertColumns (#8740)0

#6009 is not woken. Defect 3 sits in canonical-datetime territory, but the fix restores the canonicalDatetimeFields invariant 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 via declareDialectCell, 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 the NOW() 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's updateMany body gave 13 red / 5 green, and the survivors are the informative half:

  • live Postgres keeps §5 green — its parser accepts 'NOW()'. A §5 written on the PG cell alone would have measured nothing.
  • live MySQL keeps §3 green — the UTC session pin means the naive literal still names the right instant. The dialect asymmetry, observed rather than argued.
  • §6 initially went red too — a fault in §6, not a finding. It patched a json field, which defect 1 refuses outright on the unfixed driver, so it could never reach its own assertion. It now patches a plain string field 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 a trap … EXIT INT TERM restore.

Verification, at 7ffd293

Everything 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 --noEmit echoed, so not a zero-match)
  • Full driver-sql package suite under TZ=America/New_York + live PG + live MySQL + OS_EXPECT_LIVE_DIALECT_MATRIX=1Test Files 120 passed (120) · Tests 2467 passed (2467)
  • The new suite alone → 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, including check: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, since dispatch-gates.mjs does 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-bytesOK (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.ts only. Landed as declared for source, plus the new test file and the changeset:

  • packages/drivers/driver-sql/src/sql-driver.ts
  • packages/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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 8 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via createMany (sdk), data.createMany (sdk), data.updateMany (sdk), updateMany (sdk))
  • content/docs/api/data-api.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/automation/webhooks.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/http-protocol.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol))
  • content/docs/protocol/knowledge.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/releases/v16.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/releases/v17.mdx(via SqlDriver (symbol), updateMany (symbol), updateMany (sdk))

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.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0058d37d67e73e775a192f94bc462cd3552d1c31 — the merge of head 7ffd293977eb89e9571ff307ff02845896df92b3 into base 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9e7209013a258dc4411a8e444c3e4a2d49c3f8a9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 09:47
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit f24c90dAug 23, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11223-updatemany-format-input branch August 23, 2026 10:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude