Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/driver-sql-update-stamp-precision.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
---
"@objectstack/driver-sql": minor
---

fix(driver-sql): stamp `updated_at` at the audit column's own precision on MySQL, so an updated row stops reading as modified BEFORE it was created (#11224)

`createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)`
defaulted with `now(3)`, and its docblock says why in as many words
("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)`
default", #3942). `updatedAtStamp()` — the value every UPDATE door writes into
that same column — was a bare `knex.fn.now()`, which compiles to an unqualified
`CURRENT_TIMESTAMP` that MySQL truncates to whole seconds. So the column was
created at millisecond precision on purpose and then written at second precision.

Measured on live MySQL 8.0.46, against the exact schema the driver produces:

```
created_at updated_at delta
before CURRENT_TIMESTAMP 2026-08-23 10:22:36.799 2026-08-23 10:22:36.000 -799 ms
after CURRENT_TIMESTAMP(3) 2026-08-23 10:22:36.799 2026-08-23 10:22:36.802 +3 ms
```

Nothing errors. Three silent consequences, in ascending order of damage:

1. **"Last modified" precedes "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. **A delta / incremental sync SKIPS the row.** A cursor held at millisecond
precision (`updated_at > cursor`) misses every row whose stamp was truncated
back below it. Measured: all six rows in the new suite's §2 were invisible to
their own cursor immediately after being updated. This is the same
silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth
mechanism.
3. **Two updates in the same second are indistinguishable**, so an
`order by updated_at` over them is unstable exactly where it matters most.

The fix is the expression #11176 had already derived and measured for the UPSERT
door: `now(3)` on MySQL, unchanged elsewhere. Every UPDATE door reads one helper
(`update`, `updateMany`, `rotatedUpdateById`), so all three move together.

**Postgres and SQLite emit byte-identical SQL to before, and that is a
measurement rather than an assumption.** Postgres' `CURRENT_TIMESTAMP` is
`transaction_timestamp()` at microsecond precision against a `timestamptz`
column; SQLite's stamp is a JS ISO-8601 string that already carries millis.
Neither has anything to truncate. The new suite runs every cell on SQLite AND on
live Postgres AND on live MySQL, and its §5 pins which expression each dialect
gets — so a future "just add `(3)` everywhere" cannot satisfy the ordering
assertions while changing the SQL the other two dialects emit. In the baseline
run against the unfixed driver, the SQLite and Postgres cells were green (7/7
each) and only the MySQL cell was red (6 of 7).

**BREAKING**, narrowly, and the reason this is not a patch: the `protected`
`upsertUpdatedAtStamp()` that shipped in 17.2.0 with #11176 is **removed**. It
existed only to hold the precision-matched form for the upsert door without
changing the SQL every `update()` emits — a split that card made deliberately
because it had not measured the UPDATE door. This one measured it, so the pair
collapses back into the single `updatedAtStamp()`, which now carries the matched
precision for both doors. A subclass of `SqlDriver` that OVERRODE
`upsertUpdatedAtStamp()` would otherwise have kept compiling while silently
ceasing to be called, which is precisely the failure mode a release note has to
name out loud. Such a subclass should override `updatedAtStamp()` instead; the
two in-repo subclasses (`SqliteWasmDriver`, `TursoDriver`) override neither and
are unaffected. Nothing else was removed or renamed, no authored metadata
changes, and no public API moves — under this repo's launch-window convention
(breaking changes ship as `minor` while the stack versions in lockstep) `minor`
is the honest slot.

Stored data is not rewritten. Rows updated before this change keep their
truncated `updated_at`; the ordering invariant holds from the next write onward.

<!-- adr-0087: not-required (no-migration-prescription) A precision fix to the value one builtin audit column is written with, plus the removal of a `protected` driver hook that has no authorable face. No authorable key, export, config field or stored `sys_metadata` shape changes, so there is nothing for `objectstack migrate meta` or the upgrade guide to carry — a subclass that overrode the removed hook moves its override to `updatedAtStamp()`, which is a code edit rather than a metadata migration. -->
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,12 +39,16 @@
* `declareDialectCell`, so an unprovisioned dialect is REPORTED, never omitted.
*
* §4 is the guard on the OTHER branch of the same statement. The stamp lands in
* the INSERT payload too, and `updatedAtStamp()`'s bare `knex.fn.now()` compiles
* to an unqualified `CURRENT_TIMESTAMP` that MySQL truncates to whole seconds —
* against a `DATETIME(3)` column whose DEFAULT is `now(3)`. Using it here would
* make a freshly INSERTED row's `updated_at` read up to 999 ms earlier than its
* `created_at`: a new defect on a branch that had none. `upsertUpdatedAtStamp()`
* matches the column default's precision, and §4 is what measures that.
* the INSERT payload too, and a bare `knex.fn.now()` compiles to an unqualified
* `CURRENT_TIMESTAMP` that MySQL truncates to whole seconds — against a
* `DATETIME(3)` column whose DEFAULT is `now(3)`. Using it here would make a
* freshly INSERTED row's `updated_at` read up to 999 ms earlier than its
* `created_at`: a new defect on a branch that had none. This card carried the
* precision-matched form as a second helper, `upsertUpdatedAtStamp()`, so that
* fixing the upsert door did not change the SQL every `update()` emits; #11224
* then measured the UPDATE door and collapsed the pair back into the single
* `updatedAtStamp()`, which now carries the matched precision for both. §4 is
* what measures that the INSERT branch keeps it.
*
* §5 pins #7011/#8622's insert-only columns against the new payload key:
* `created_at` is the row's birth instant and must not move when the merge
Expand DownExpand Up@@ -83,12 +87,17 @@ const OPTS = { bypassTenantAudit: true } as any;
/**
* The instant a row is backdated to before the write under test.
*
* A sentinel far in the past rather than a sleep, for #11067's reason: MySQL's
* unqualified `CURRENT_TIMESTAMP` carries no fractional digits, so a stamp a few
* hundred ms after an insert default of `current_timestamp(3)` can legitimately
* land on the same stored value. Backdating removes the race without weakening
* the assertion — the stamp either moved to ~now or did not move at all, and
* those are six years apart.
* A sentinel far in the past rather than a sleep, for #11067's reason: a stamp
* taken a moment after an insert default can legitimately land on the same
* stored value. Backdating removes the race without weakening the assertion —
* the stamp either moved to ~now or did not move at all, and those are six
* years apart.
*
* The reason used to be coarser on MySQL specifically: `updatedAtStamp()`'s
* unqualified `CURRENT_TIMESTAMP` carried no fractional digits at all, so a
* stamp a few hundred MILLISECONDS later still landed on the same value. #11224
* gave that helper the column default's `now(3)` precision, so that window is
* now sub-millisecond on every dialect.
*/
const BACKDATED_MS = Date.parse('2020-01-01T00:00:00.000Z');

Expand Down
Loading
Loading