Found while fixing #10995 (same root cause, deliberately out of that PR's scope).
What
SqlDriver.update() refreshes updated_at only for tables in tablesWithTimestamps:
if(this.tablesWithTimestamps.has(object)&&!this.keepSuppliedUpdatedAt(formatted,options)){formatted.updated_at=this.isSqlite ? newDate().toISOString() : this.knex.fn.now();}That set is populated in exactly three places, all inside the DDL path of initObjects
(packages/drivers/driver-sql/src/sql-driver.ts): the createTable branch, the
"existing table already has an updated_at column" branch, and the rotation-shard copy.
So a deployment that manages DDL out-of-band — skipSchemaSync / OS_SKIP_SCHEMA_SYNC=1,
documented in content/docs/deployment/environment-variables.mdx as "skip the implicit
db:sync on boot; use after running migrations manually" — boots with that set empty
and never stamps updated_at on any update. Nothing else covers it: the column is created
with DEFAULT now() only (createAuditTimestampColumn), i.e. an INSERT-time default, and
no ON UPDATE clause or trigger on any dialect.
Consequence: on those deployments updated_at records the row's creation time forever.
Anything reading it as "last modified" is silently wrong — the by_user / all_preferences
list views sort on it, incremental/delta sync and cache-invalidation consumers read it, and
audit answers derived from it are wrong without being unavailable.
Why it is not fixed with #10995
#10995 makes the value-encoding registries installable without DDL
(SqlDriver.registerObjectMetadata(), called by a skipSchemaSync boot). Those registries
are derived purely from the object's DECLARED field types, so registration stays in-memory
and costs no round-trip — which is the whole reason the flag exists.
tablesWithTimestamps is different in kind: on the DDL path it is decided from the
physical columns (columnInfo() — "does this table actually have updated_at?"), and
that answer cannot be produced without touching the database. So the fix needs a decision
that PR did not want to make silently:
- Infer from the declared shape. Every table this driver's own DDL creates gets
created_at/updated_at unconditionally, so a managed object could join the set at
registration time with no probe. Wrong for a hand-migrated table that genuinely lacks the
column — an update would then write a column that does not exist (a loud failure, not a
silent one). - Probe once, lazily, per table. One
columnInfo() on first write to a table not yet
classified. Correct on any physical shape, at the cost of one round-trip per table per
process — small, but it is exactly the currency skipSchemaSync is spending. - Declare it. Have the out-of-band migration path record the fact, so the serving
process is told rather than guessing.
Repro sketch
Create the table out-of-band (create table t (id text primary key, key text, value jsonb, created_at timestamptz default now(), updated_at timestamptz default now())), construct a
SqlDriver that never runs initObjects, create() a row, wait, update() it, and read
updated_at back: unchanged. The same write through a driver that ran initObjects stamps it.
Same family as #10995: driver state that the write path's correctness depends on is
installed only as a side effect of DDL.
Found while fixing #10995 (same root cause, deliberately out of that PR's scope).
What
SqlDriver.update()refreshesupdated_atonly for tables intablesWithTimestamps:That set is populated in exactly three places, all inside the DDL path of
initObjects(
packages/drivers/driver-sql/src/sql-driver.ts): thecreateTablebranch, the"existing table already has an
updated_atcolumn" branch, and the rotation-shard copy.So a deployment that manages DDL out-of-band —
skipSchemaSync/OS_SKIP_SCHEMA_SYNC=1,documented in
content/docs/deployment/environment-variables.mdxas "skip the implicitdb:syncon boot; use after running migrations manually" — boots with that set emptyand never stamps
updated_aton any update. Nothing else covers it: the column is createdwith
DEFAULT now()only (createAuditTimestampColumn), i.e. an INSERT-time default, andno
ON UPDATEclause or trigger on any dialect.Consequence: on those deployments
updated_atrecords the row's creation time forever.Anything reading it as "last modified" is silently wrong — the
by_user/all_preferenceslist views sort on it, incremental/delta sync and cache-invalidation consumers read it, and
audit answers derived from it are wrong without being unavailable.
Why it is not fixed with #10995
#10995 makes the value-encoding registries installable without DDL
(
SqlDriver.registerObjectMetadata(), called by askipSchemaSyncboot). Those registriesare derived purely from the object's DECLARED field types, so registration stays in-memory
and costs no round-trip — which is the whole reason the flag exists.
tablesWithTimestampsis different in kind: on the DDL path it is decided from thephysical columns (
columnInfo()— "does this table actually haveupdated_at?"), andthat answer cannot be produced without touching the database. So the fix needs a decision
that PR did not want to make silently:
created_at/updated_atunconditionally, so a managed object could join the set atregistration time with no probe. Wrong for a hand-migrated table that genuinely lacks the
column — an
updatewould then write a column that does not exist (a loud failure, not asilent one).
columnInfo()on first write to a table not yetclassified. Correct on any physical shape, at the cost of one round-trip per table per
process — small, but it is exactly the currency
skipSchemaSyncis spending.process is told rather than guessing.
Repro sketch
Create the table out-of-band (
create table t (id text primary key, key text, value jsonb, created_at timestamptz default now(), updated_at timestamptz default now())), construct aSqlDriverthat never runsinitObjects,create()a row, wait,update()it, and readupdated_atback: unchanged. The same write through a driver that raninitObjectsstamps it.Same family as #10995: driver state that the write path's correctness depends on is
installed only as a side effect of DDL.