Skip to content

driver-sql: updateMany() bypasses formatInput / applyWriteColumnMap — json and array writes are refused, a federated columnMap object's bulk update names a column that does not exist, and SQLite datetime writes land non-canonical #11223

Description

@os-zhuang

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.tsupdateMany() 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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions