Skip to content

driver-sql: on MySQL the UPDATE door's updated_at stamp truncates to whole seconds against a DATETIME(3) column — a row updated in its first second reads updated_at EARLIER than created_at #11224

Description

@os-zhuang

Found while choosing the stamp expression for #11176's upsert path. Not fixed there: that card is about whether updated_at advances, this one is about the fidelity of the value it advances to, and fixing it changes the SQL every update() on MySQL emits.

The mismatch

packages/drivers/driver-sql/src/sql-driver.ts:

So the column is created with millisecond precision on purpose and then written at second precision.

Measured, live MySQL 8.0.46

Against the exact schema createAuditTimestampColumn produces:

AFTER INSERT (both columns from the DEFAULT now(3))
created_at 2026-08-23 11:59:33.357 updated_at 2026-08-23 11:59:33.357
AFTER an update() stamp — CURRENT_TIMESTAMP, what updatedAtStamp() emits
created_at 2026-08-23 11:59:33.357 updated_at 2026-08-23 11:59:33.000
-> updated_at is EARLIER than created_at, by 357 ms
AFTER the precision-matched form CURRENT_TIMESTAMP(3)
created_at 2026-08-23 11:59:33.357 updated_at 2026-08-23 11:59:33.361 ok

Consequences

  1. "Last modified" can precede "created". Any consumer comparing the two — an audit answer, a "modified since creation?" badge, a data-quality check — reads a row that was modified as if it were not.
  2. Delta / incremental sync can SKIP a row. A cursor held at millisecond precision (updated_at > cursor) misses every row whose stamp was truncated back below the cursor. That is the same silent-wrong-answer family as driver-sql: updated_at is never refreshed on a deployment that skips boot schema sync — tablesWithTimestamps is also only filled by DDL #11067 and driver-sql: updateMany() never stamps updated_at, and upsert()'s merge branch does not advance it on Postgres/MySQL — on every deployment, DDL or not #11176, reached by a different mechanism.
  3. Two updates in the same second are indistinguishable, so an ordering by updated_at is unstable exactly where it matters most.

Postgres is unaffected: CURRENT_TIMESTAMP there is transaction_timestamp() at microsecond precision, and the column is timestamptz. SQLite is unaffected: updatedAtStamp() returns a full ISO-8601 string with millis there.

This is already half-known in the repo — sql-driver-timestamps-without-ddl.test.ts documents the truncation in its BACKDATED_MS docblock as a reason to backdate rather than sleep, but records it as a testing hazard rather than as a defect in the stored value.

Suggested shape (not decided here)

Give updatedAtStamp() the same precision the column default carries — this.knex.fn.now(3) on MySQL, unchanged elsewhere. #11176's upsertUpdatedAtStamp() already does exactly this for the upsert door and carries the reasoning at the site; the two would then collapse into one helper. Deliberately left as two by that PR so the UPDATE door's emitted SQL was not changed by a card that had not measured it.

Note the ordering constraint if both land: whoever converges them owns re-running the live MySQL cells of the driver-sql matrix, since the emitted SQL for every update() on MySQL changes.


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