You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
updatedAtStamp() — the value every UPDATE door writes into that same column — returns a bare this.knex.fn.now(), which compiles to an unqualified CURRENT_TIMESTAMP. MySQL truncates that to whole seconds.
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
"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.
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.
Found while choosing the stamp expression for #11176's upsert path. Not fixed there: that card is about whether
updated_atadvances, this one is about the fidelity of the value it advances to, and fixing it changes the SQL everyupdate()on MySQL emits.The mismatch
packages/drivers/driver-sql/src/sql-driver.ts:createAuditTimestampColumnbuilds the audit columns on MySQL asDATETIME(3)defaulted withthis.knex.fn.now(3), and its docblock says why in as many words: "CURRENT_TIMESTAMPhas to carry matching precision for aDATETIME(3)default, hencenow(3)" (MySQL: Field.datetime maps to TIMESTAMP (range ends 2038-01-19) and binds a Date in the process-local timezone #3942).updatedAtStamp()— the value every UPDATE door writes into that same column — returns a barethis.knex.fn.now(), which compiles to an unqualifiedCURRENT_TIMESTAMP. MySQL truncates that to whole seconds.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
createAuditTimestampColumnproduces:Consequences
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_atis never refreshed on a deployment that skips boot schema sync —tablesWithTimestampsis also only filled by DDL #11067 and driver-sql:updateMany()never stampsupdated_at, andupsert()'s merge branch does not advance it on Postgres/MySQL — on every deployment, DDL or not #11176, reached by a different mechanism.updated_atis unstable exactly where it matters most.Postgres is unaffected:
CURRENT_TIMESTAMPthere istransaction_timestamp()at microsecond precision, and the column istimestamptz. 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.tsdocuments the truncation in itsBACKDATED_MSdocblock 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'supsertUpdatedAtStamp()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