Found while fixing #11067 (deliberately out of that PR's scope — different mechanism, different fix).
#11067 is about updated_at not being stamped on skipSchemaSync deployments, because tablesWithTimestamps is only filled by DDL. These two are not that: they are missing on every deployment, including a fully DDL-managed one where tablesWithTimestamps is correctly populated.
1. updateMany() stamps nothing
packages/drivers/driver-sql/src/sql-driver.ts:
asyncupdateMany(object: string,query: DriverQuery,data: any,options?: DriverOptions): Promise<number>{this.auditMissingTenant(object,'updateMany',options);lettotal=0;for(consttargetofthis.rotationShardsOf(object)??[object]){constbuilder=this.getBuilder(target,options);this.applyTenantScope(builder,object,options);if(query.where)this.applyFilters(builder,query.where);total+=(awaitbuilder.update(data))||0;}returntotal;}No tablesWithTimestamps consultation, no updated_at. update() and rotatedUpdateById() both stamp; updateMany() does not. A bulk edit therefore leaves every row it touched reading its previous updated_at.
Worth noting separately during triage: updateMany also passes data straight through, without formatInput / applyWriteColumnMap — unlike every other write path. That may be deliberate or may be a second defect; it is not measured here.
2. upsert()'s merge branch does not advance updated_at on Postgres/MySQL
The merge site carries this comment:
Everything else (incl. updated_at) merges as before, so an upsert that updates a row still advances updated_at.
That holds only when updated_at is present in formatted, which requires either the caller to supply it or stampInsertTimestamps to have put it there — and stampInsertTimestamps returns early on any non-SQLite dialect (if (!this.isSqlite || …) return;). So SQLite is accidentally correct and Postgres/MySQL are not. The INSERT … DEFAULT now() does not re-fire on the conflict path.
The neighbouring #8622 comment states the SQLite half of this from the other direction — "(SQLite never reached it: stampInsertTimestamps puts a mergeable updated_at in the payload there.)" — so the asymmetry is already known at the site; only its consequence for updated_at was not drawn.
Measured on live PostgreSQL 16.13, through initObjects (so tablesWithTimestamps IS filled), 1.2 s between the two calls:
DDL path (tablesWithTimestamps filled), PG upsert-that-merges:
title : second <- the merge landed
created_at : 2026-08-23T00:17:40.602Z
updated_at : 2026-08-23T00:17:40.602Z <- unchanged: still the INSERT default
ADVANCED? : false
update() ADVANCED? : true <- same table, same driver, contrast
Consequence
Same shape as #11067 and the same consumers: list-view sorts, delta/incremental sync, cache invalidation and audit answers read updated_at as "last modified". Both paths leave it stale without erroring, so nothing is unavailable — the answers are just wrong. A bulk status change (updateMany) and a sync/import that upserts (the common shape for connector and seed writes) are exactly the operations most likely to be feeding a downstream delta consumer.
Not fixed in #11067's PR
Deliberately: #11067's fix is about the DDL-coupling of tablesWithTimestamps, and it neither introduces nor widens either of these. Whether bulk and merge writes should advance updated_at is one decision covering both paths, which is why they are filed together rather than split.
Found while fixing #11067 (deliberately out of that PR's scope — different mechanism, different fix).
#11067 is about
updated_atnot being stamped onskipSchemaSyncdeployments, becausetablesWithTimestampsis only filled by DDL. These two are not that: they are missing on every deployment, including a fully DDL-managed one wheretablesWithTimestampsis correctly populated.1.
updateMany()stamps nothingpackages/drivers/driver-sql/src/sql-driver.ts:No
tablesWithTimestampsconsultation, noupdated_at.update()androtatedUpdateById()both stamp;updateMany()does not. A bulk edit therefore leaves every row it touched reading its previousupdated_at.Worth noting separately during triage:
updateManyalso passesdatastraight through, withoutformatInput/applyWriteColumnMap— unlike every other write path. That may be deliberate or may be a second defect; it is not measured here.2.
upsert()'s merge branch does not advanceupdated_aton Postgres/MySQLThe merge site carries this comment:
That holds only when
updated_atis present informatted, which requires either the caller to supply it orstampInsertTimestampsto have put it there — andstampInsertTimestampsreturns early on any non-SQLite dialect (if (!this.isSqlite || …) return;). So SQLite is accidentally correct and Postgres/MySQL are not. TheINSERT … DEFAULT now()does not re-fire on the conflict path.The neighbouring #8622 comment states the SQLite half of this from the other direction — "(SQLite never reached it:
stampInsertTimestampsputs a mergeableupdated_atin the payload there.)" — so the asymmetry is already known at the site; only its consequence forupdated_atwas not drawn.Measured on live PostgreSQL 16.13, through
initObjects(sotablesWithTimestampsIS filled), 1.2 s between the two calls:Consequence
Same shape as #11067 and the same consumers: list-view sorts, delta/incremental sync, cache invalidation and audit answers read
updated_atas "last modified". Both paths leave it stale without erroring, so nothing is unavailable — the answers are just wrong. A bulk status change (updateMany) and a sync/import that upserts (the common shape for connector and seed writes) are exactly the operations most likely to be feeding a downstream delta consumer.Not fixed in #11067's PR
Deliberately: #11067's fix is about the DDL-coupling of
tablesWithTimestamps, and it neither introduces nor widens either of these. Whether bulk and merge writes should advanceupdated_atis one decision covering both paths, which is why they are filed together rather than split.