Measured while implementing #11176, which noted the gap without measuring it ("That may be deliberate or may be a second defect; it is not measured here"). It is a second defect, and it is three defects wearing one cause. Deliberately not fixed in that PR: it changes which VALUES a caller may write, not whether updated_at advances.
The cause, one line
packages/drivers/driver-sql/src/sql-driver.ts — updateMany() passes the caller's data straight to builder.update(data). Every other write door in the file goes through this.applyWriteColumnMap(object, this.formatInput(object, data)) first: create, update, bulkCreate, upsert, rotatedUpdateById. updateMany is the only one that does not.
Note the WHERE side of the same call IS translated — applyFilters maps logical field to physical column. So on a federated object the filter finds the row and the SET clause then names a column that is not there.
Measured, live
PostgreSQL 16.13 and MySQL 8.0.46 in-container, plus SQLite, through the driver's own initObjects.
1. json / array values are REFUSED (loud) — live Postgres
Object with payload: { type: 'json' } and tags: { type: 'string', multiple: true }.
update() payload = {"a":2} tags = ["y","z"] <- correct
updateMany() threw 22P02:
update "probe_json" set "payload" = $1, "tags" = $2, "updated_at" = CURRENT_TIMESTAMP where "id" = $3
- invalid input syntax for type json
Same shape on SQLite, with the dialect's own words:
updateMany() threw: SQLite3 can only bind numbers, strings, bigints, buffers, and null
stored value unchanged: {"a":1}
formatInput is what stringifies a structured value for the bind; without it node-postgres and better-sqlite3 each bind the raw JS object.
2. A federated columnMap object's bulk update names a column that does not exist (loud) — every dialect
ADR-0015 section 18 fixture shape: external.columnMap binding local name to remote full_name.
update() -> {"cust_id":"c1","full_name":"Aurora"} <- mapped, correct
updateMany() threw: update `legacy_p` set `name` = 'Bulk' where `full_name` = 'Renamed'
- no such column: name
The WHERE is mapped and the SET is not, in one statement. So updateMany is unusable on every federated object that remaps any column — the objects federation exists to serve.
3. SQLite datetime values are stored NON-CANONICAL (silent) — the dangerous one
Field.datetime column, same object, same value shape:
update() when = "2026-03-04T05:06:07.000Z" <- canonicalized by formatInput
updateMany() when = "2026-05-06 07:08:09" <- stored raw
No error. That second form is exactly the zone-naive, space-separated pre-#3912 storage form needsLegacyDatetimeRepair exists to repair on read, and it is being NEWLY written today. It also contradicts canonicalDatetimeFields, whose whole claim is "proven to hold ONLY canonical UTC text — either backfilled by backfillCanonicalDatetimes or created empty in this process": a table that satisfies that claim can be pushed out of it by one updateMany, after which the driver has dropped the read-side repair expression for it. Date.parse reads the naive form as LOCAL time, so the instant shifts by the host offset — the objectos freshness-probe miss, re-armed.
date and time fields have the same coercion in formatInput and are presumably affected the same way; only datetime was measured.
Why this is not "deliberate"
Nothing in the file says so, and the three consequences above are not a coherent policy: two are hard failures on inputs the other doors accept, and the third is a silent regression of a storage-form invariant the driver asserts elsewhere. The likeliest reading is that updateMany predates the coercion registries and was never revisited.
Suggested shape (not decided here)
Route updateMany's payload through the same pair the other doors use. That is accept-set widening on legs 1 and 2 (calls that fail today would start working) and a storage-form correction on leg 3. Worth checking whether any consumer depends on the raw pass-through before assuming it is free — and worth deciding whether the NOW() token and the runtime-default token, which formatInput also resolves, should resolve on this door too.
Not this card
The updated_at stamp itself, which #11176 adds to this door, is unaffected: it is written as the literal post-map column name (the same spelling created_at carries everywhere in this file) and is applied after the payload is assembled, so it is correct with or without this fix. The emitted SQL above shows it landing correctly in all three probes.
Generated by Claude Code
Measured while implementing #11176, which noted the gap without measuring it ("That may be deliberate or may be a second defect; it is not measured here"). It is a second defect, and it is three defects wearing one cause. Deliberately not fixed in that PR: it changes which VALUES a caller may write, not whether
updated_atadvances.The cause, one line
packages/drivers/driver-sql/src/sql-driver.ts—updateMany()passes the caller'sdatastraight tobuilder.update(data). Every other write door in the file goes throughthis.applyWriteColumnMap(object, this.formatInput(object, data))first:create,update,bulkCreate,upsert,rotatedUpdateById.updateManyis the only one that does not.Note the WHERE side of the same call IS translated —
applyFiltersmaps logical field to physical column. So on a federated object the filter finds the row and the SET clause then names a column that is not there.Measured, live
PostgreSQL 16.13 and MySQL 8.0.46 in-container, plus SQLite, through the driver's own
initObjects.1. json / array values are REFUSED (loud) — live Postgres
Object with
payload: { type: 'json' }andtags: { type: 'string', multiple: true }.Same shape on SQLite, with the dialect's own words:
formatInputis what stringifies a structured value for the bind; without it node-postgres and better-sqlite3 each bind the raw JS object.2. A federated
columnMapobject's bulk update names a column that does not exist (loud) — every dialectADR-0015 section 18 fixture shape:
external.columnMapbinding localnameto remotefull_name.The WHERE is mapped and the SET is not, in one statement. So
updateManyis unusable on every federated object that remaps any column — the objects federation exists to serve.3. SQLite datetime values are stored NON-CANONICAL (silent) — the dangerous one
Field.datetimecolumn, same object, same value shape:No error. That second form is exactly the zone-naive, space-separated pre-#3912 storage form
needsLegacyDatetimeRepairexists to repair on read, and it is being NEWLY written today. It also contradictscanonicalDatetimeFields, whose whole claim is "proven to hold ONLY canonical UTC text — either backfilled bybackfillCanonicalDatetimesor created empty in this process": a table that satisfies that claim can be pushed out of it by oneupdateMany, after which the driver has dropped the read-side repair expression for it.Date.parsereads the naive form as LOCAL time, so the instant shifts by the host offset — the objectos freshness-probe miss, re-armed.dateandtimefields have the same coercion informatInputand are presumably affected the same way; onlydatetimewas measured.Why this is not "deliberate"
Nothing in the file says so, and the three consequences above are not a coherent policy: two are hard failures on inputs the other doors accept, and the third is a silent regression of a storage-form invariant the driver asserts elsewhere. The likeliest reading is that
updateManypredates the coercion registries and was never revisited.Suggested shape (not decided here)
Route
updateMany's payload through the same pair the other doors use. That is accept-set widening on legs 1 and 2 (calls that fail today would start working) and a storage-form correction on leg 3. Worth checking whether any consumer depends on the raw pass-through before assuming it is free — and worth deciding whether theNOW()token and the runtime-default token, whichformatInputalso resolves, should resolve on this door too.Not this card
The
updated_atstamp itself, which #11176 adds to this door, is unaffected: it is written as the literal post-map column name (the same spellingcreated_atcarries everywhere in this file) and is applied after the payload is assembled, so it is correct with or without this fix. The emitted SQL above shows it landing correctly in all three probes.Generated by Claude Code