Skip to content

driver-sql: updated_at is never refreshed on a deployment that skips boot schema sync — tablesWithTimestamps is also only filled by DDL #11067

Description

@os-elon

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:

  1. 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).
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions