diff --git a/.changeset/driver-sql-update-stamp-precision.md b/.changeset/driver-sql-update-stamp-precision.md new file mode 100644 index 0000000000..099f3e0a85 --- /dev/null +++ b/.changeset/driver-sql-update-stamp-precision.md @@ -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. + + diff --git a/packages/drivers/driver-sql/src/sql-driver-11176-bulk-and-merge-updated-at.test.ts b/packages/drivers/driver-sql/src/sql-driver-11176-bulk-and-merge-updated-at.test.ts index dcd316b500..098aef7f70 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11176-bulk-and-merge-updated-at.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11176-bulk-and-merge-updated-at.test.ts @@ -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 @@ -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'); diff --git a/packages/drivers/driver-sql/src/sql-driver-11224-update-stamp-precision.test.ts b/packages/drivers/driver-sql/src/sql-driver-11224-update-stamp-precision.test.ts new file mode 100644 index 0000000000..3f0ba3fe2d --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11224-update-stamp-precision.test.ts @@ -0,0 +1,325 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11224] The UPDATE door's `updated_at` stamp must carry the SAME precision + * the audit column was created with — measured as the ordering invariant a + * delta cursor depends on, not as a string shape. + * + * ## The mismatch this closes + * + * `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 11:59:33.357 + * update() 11:59:33.000 ← updated_at 357 ms EARLIER than created_at + * ``` + * + * ## Not #11176, and not a second derivation of the right answer + * + * #11176 landed `upsertUpdatedAtStamp()` — the precision-matched form — for the + * UPSERT door only, deliberately leaving the UPDATE door's emitted SQL alone + * because that card had not measured it. This one measures it, so the two + * expressions collapse into ONE helper. §4 pins that collapse behaviourally: + * whatever the upsert door puts in its payload is what the UPDATE door stamps, + * character for character. + * + * ## Why the ordering invariant rather than the rendered SQL + * + * `updated_at >= created_at` is the property a consumer actually reads: an + * audit answer comparing the two, a "modified since creation?" badge, and above + * all a millisecond-precision delta cursor (`updated_at > cursor`), which + * SKIPS every row whose stamp was truncated back below it — the same + * silent-wrong-answer family as #11067 / #11176 / #11223, reached by a fourth + * mechanism. §2 asserts that skip is gone by issuing the cursor comparison as + * real SQL on the server rather than comparing numbers in JS. + * + * ## Non-vacuity + * + * Truncation is only OBSERVABLE on a row whose `created_at` carried a non-zero + * millisecond component — roughly 999 inserts in 1000, but not all of them. So + * every ordering cell runs {@link ROUNDS} independent create+update pairs and + * asserts at least one of them carried sub-second digits. Without that guard a + * run in which every `created_at` happened to land on `.000` would report a + * green that no truncation could have perturbed. + * + * Every cell runs on SQLite AND on live Postgres / MySQL through + * `declareDialectCell`, because "the other dialects do not regress" is a + * measurement here and not an assumption: Postgres' `CURRENT_TIMESTAMP` is + * `transaction_timestamp()` at microsecond precision and SQLite's stamp is a + * JS ISO-8601 string with millis, so neither has anything to truncate — and the + * emitted SQL on both is asserted UNCHANGED by §5. + * + * ## Reverse verification (direction predicted before running) + * + * Restoring `main`'s `updatedAtStamp()` body turns §1, §2 and §3 red on the + * live MySQL cell ONLY — with `updated_at` reading a few hundred ms EARLIER + * than `created_at`, the defect itself as the received value — and turns §4 red + * on that cell too (`CURRENT_TIMESTAMP` vs `CURRENT_TIMESTAMP(3)`). The SQLite + * and Postgres cells stay green throughout: the asymmetry, observed rather than + * argued. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqlDriver } from './index.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +/** Driver options every write here uses — these fixtures are not tenant-scoped. */ +const OPTS = { bypassTenantAudit: true } as any; + +/** + * How many independent create+update pairs each ordering cell measures. + * + * Sized for the non-vacuity guard above rather than for coverage: the chance + * that all six `created_at` defaults land on an exact `.000` millisecond is + * ~1e-18, so a run that cannot observe truncation fails loudly instead of + * passing vacuously. + */ +const ROUNDS = 6; + +const MANAGED = 'os11224_stamp'; +const BULK = 'os11224_bulk'; + +/** ISO-8601 with an explicit `Z` — the shape SQLite's stamp must keep (#3493 lineage). */ +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function managedObject(name: string) { + return { + name, + fields: { + id: { type: 'text' }, + title: { type: 'string' }, + status: { type: 'string' }, + }, + } as any; +} + +/** Whatever the dialect handed back for an audit column, as epoch ms. */ +function asInstant(value: unknown): number { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return value; + const text = String(value); + // SQLite stores TEXT. The canonical form already carries `Z`; a zone-naive + // legacy form is read as UTC here rather than as host-local, matching what + // `repairNaiveUtcAuditTimestamp` does on the read path. + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text.replace(' ', 'T')}Z`); +} + +/** Read the audit columns straight out of storage, past every read-side coercion. */ +async function readAudit(driver: SqlDriver, table: string, id: string) { + const row: any = await (driver as any).knex(table).where('id', id).first(); + return { + createdAt: asInstant(row.created_at), + updatedAt: asInstant(row.updated_at), + /** The stored value in the dialect's OWN shape — what a cursor round-trips. */ + rawCreatedAt: row.created_at, + row, + }; +} + +function measure(cell: DialectCell): void { + describe(`#11224 — the UPDATE door stamps at the audit column's precision (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + // The DDL path, so the audit columns are the ones + // `createAuditTimestampColumn` produces — `DATETIME(3) default now(3)` on + // MySQL. That pairing is the whole defect. + await driver.initObjects([managedObject(MANAGED), managedObject(BULK)]); + }); + + afterAll(async () => { + await driver?.disconnect(); + }); + + // ── §1 The ordering invariant, on a row updated inside its first second ── + + it('§1 never leaves `updated_at` EARLIER than `created_at`', async () => { + const seen: { id: string; createdAt: number; updatedAt: number }[] = []; + for (let i = 0; i < ROUNDS; i++) { + const id = `s${i}`; + await driver.create(MANAGED, { id, title: 'one', status: 'open' }, OPTS); + // No sleep and no backdating: the row is updated inside the same second + // it was created in, which is exactly where a truncated stamp lands + // BEHIND the column's own `now(3)` default. + await driver.update(MANAGED, id, { title: 'two' }, OPTS); + seen.push({ id, ...(await readAudit(driver, MANAGED, id)) }); + } + + // Non-vacuity: truncation is invisible on a `created_at` of exactly `.000`. + const withMillis = seen.filter((r) => r.createdAt % 1000 !== 0); + expect( + withMillis.length, + 'no `created_at` in this run carried sub-second digits, so nothing here could ' + + 'have observed a truncated stamp — the cell measured nothing', + ).toBeGreaterThan(0); + + for (const r of seen) { + // The defect: on MySQL this was `created_at` truncated to the second, + // i.e. up to 999 ms in the PAST of a row that had just been created. + expect( + r.updatedAt, + `${r.id}: updated_at ${new Date(r.updatedAt).toISOString()} is EARLIER than ` + + `created_at ${new Date(r.createdAt).toISOString()}`, + ).toBeGreaterThanOrEqual(r.createdAt); + } + }); + + it('§1b holds for the BULK door too — one helper, every UPDATE path', async () => { + // `update`, `updateMany` and `rotatedUpdateById` all read the same + // `updatedAtStamp()`, so fixing the expression fixes every one of them. + // This is the door that would silently keep the old value if a future + // change gave it a private copy. + const ids: string[] = []; + for (let i = 0; i < ROUNDS; i++) { + const id = `b${i}`; + ids.push(id); + await driver.create(BULK, { id, title: 'one', status: 'open' }, OPTS); + } + const affected = await driver.updateMany(BULK, { where: { status: 'open' } }, { title: 'two' }, OPTS); + expect(affected).toBe(ROUNDS); + + const rows = await Promise.all(ids.map((id) => readAudit(driver, BULK, id))); + expect(rows.filter((r) => r.createdAt % 1000 !== 0).length).toBeGreaterThan(0); + for (const r of rows) expect(r.updatedAt).toBeGreaterThanOrEqual(r.createdAt); + }); + + // ── §2 The delta cursor, issued as real SQL on the server ──────────────── + + it('§2 a millisecond-precision delta cursor does not SKIP the updated row', async () => { + // The consequence that makes this a silent wrong answer rather than a + // cosmetic one. The comparison is deliberately made BY THE SERVER, in the + // column's own type, because that is where an incremental sync makes it — + // comparing two JS numbers here would measure this test's own parsing. + const knex = (driver as any).knex; + const missed: string[] = []; + let observable = 0; + + for (let i = 0; i < ROUNDS; i++) { + const id = `c${i}`; + await driver.create(MANAGED, { id, title: 'one', status: 'sync' }, OPTS); + // The cursor a delta sync would be holding: the last instant it saw for + // this row, at the full precision the column stores. + const before = await readAudit(driver, MANAGED, id); + if (before.createdAt % 1000 !== 0) observable++; + + await driver.update(MANAGED, id, { title: 'two' }, OPTS); + + const found = await knex(MANAGED).where('id', id).where('updated_at', '>=', before.rawCreatedAt).first(); + if (!found) missed.push(id); + } + + expect( + observable, + 'no cursor in this run sat at a sub-second offset, so no truncation could have ' + + 'moved a row below it — the cell measured nothing', + ).toBeGreaterThan(0); + // The defect: every row whose `created_at` carried millis was invisible to + // its own cursor after being updated, so a delta sync silently dropped it. + expect(missed, `rows skipped by their own delta cursor after update(): ${missed.join(', ')}`).toEqual([]); + }); + + // ── §3 Two updates in the same second stay distinguishable ─────────────── + + it('§3 keeps sub-second resolution, so same-second updates are ordered', async () => { + // Truncation collapses every update inside one second onto one value, so + // an `order by updated_at` over them is unstable exactly where it matters. + const id = 'd1'; + await driver.create(MANAGED, { id, title: 'one', status: 'seq' }, OPTS); + const stamps: number[] = []; + for (let i = 0; i < ROUNDS; i++) { + await driver.update(MANAGED, id, { title: `t${i}` }, OPTS); + stamps.push((await readAudit(driver, MANAGED, id)).updatedAt); + } + // Monotone regardless (the invariant), and — the point — the run spans + // less than the full second a truncated stamp would need to distinguish + // any two of these at all. + for (let i = 1; i < stamps.length; i++) expect(stamps[i]).toBeGreaterThanOrEqual(stamps[i - 1]); + const span = stamps[stamps.length - 1] - stamps[0]; + expect(span, 'this run took over a second, so second-precision stamps could have differed too').toBeLessThan( + 1_000, + ); + expect(new Set(stamps).size, 'every update in this second stamped the SAME instant').toBeGreaterThan(1); + }); + + // ── §4 The two stamp helpers collapsed into one ────────────────────────── + + it('§4 stamps the UPSERT door and the UPDATE door with the identical expression', async () => { + // #11176 carried the precision-matched form as a SECOND helper + // (`upsertUpdatedAtStamp`) so it could fix the upsert door without + // changing the SQL every `update()` emits. This card measured that SQL, so + // there is one helper again — and this is the assertion that stays red if + // a future change re-forks them. + const upsertPayload: Record = {}; + (driver as any).stampUpsertUpdatedAt(MANAGED, upsertPayload); + const updateStamp = (driver as any).updatedAtStamp(); + + if (cell.id === 'sqlite') { + // Both are JS-side ISO strings, so they differ by the millisecond they + // were taken in; the SHAPE is what has to agree. + expect(String(upsertPayload.updated_at)).toMatch(ISO_Z); + expect(String(updateStamp)).toMatch(ISO_Z); + } else { + // Rendered SQL, character for character. Before this card the MySQL cell + // read `CURRENT_TIMESTAMP(3)` here and `CURRENT_TIMESTAMP` there. + expect(String(upsertPayload.updated_at)).toBe(String(updateStamp)); + } + }); + + // ── §5 The other dialects' emitted SQL is UNCHANGED ────────────────────── + + it('§5 emits the expression this dialect defaults its audit column with', async () => { + // Not a restatement of §1: this pins WHICH form each dialect gets, so a + // future "just add (3) everywhere" cannot pass §1 while changing the SQL + // Postgres and SQLite emit. + const stamp = String((driver as any).updatedAtStamp()); + switch (cell.id) { + case 'mysql': + // Matched to `createAuditTimestampColumn`'s `DATETIME(3) default now(3)` (#3942). + expect(stamp).toBe('CURRENT_TIMESTAMP(3)'); + break; + case 'pg': + // Unchanged: `CURRENT_TIMESTAMP` is `transaction_timestamp()` at + // microsecond precision against a `timestamptz` column — nothing to match. + expect(stamp).toBe('CURRENT_TIMESTAMP'); + break; + default: + // Unchanged: SQLite has no temporal type, and the stamp is the same + // zone-EXPLICIT ISO-8601 string the insert paths write. + expect(stamp).toMatch(ISO_Z); + } + }); + + // ── §6 #3493's historical import still wins ────────────────────────────── + + it('§6 still preserves a caller-supplied `updated_at` under `preserveAudit`', async () => { + // The precision change must not reach the one caller allowed to pin the + // value — otherwise a historical import silently starts being force-advanced. + const id = 'e1'; + const supplied = new Date(Date.parse('2020-01-01T00:00:00.000Z')); + await driver.create(MANAGED, { id, title: 'one', status: 'import' }, OPTS); + await driver.update( + MANAGED, + id, + { title: 'two', updated_at: (driver as any).isSqlite ? supplied.toISOString() : supplied }, + { ...OPTS, preserveAudit: true }, + ); + const after = await readAudit(driver, MANAGED, id); + expect(after.updatedAt).toBe(supplied.getTime()); + expect(after.row.title).toBe('two'); + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'update stamp precision (#11224)', measure); +} diff --git a/packages/drivers/driver-sql/src/sql-driver-timestamps-without-ddl.test.ts b/packages/drivers/driver-sql/src/sql-driver-timestamps-without-ddl.test.ts index 93b2a4b1d2..e73ce4ec85 100644 --- a/packages/drivers/driver-sql/src/sql-driver-timestamps-without-ddl.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-timestamps-without-ddl.test.ts @@ -75,12 +75,18 @@ const OPTS = { bypassTenantAudit: true }; /** * The instant a row is backdated to before the `update()` under test. * - * A sentinel far in the past rather than a sleep: `knex.fn.now()` compiles to - * MySQL's `CURRENT_TIMESTAMP`, which carries NO fractional digits, so an insert - * default of `current_timestamp(3)` and an update a few hundred ms later can - * legitimately land on the same — or an earlier — stored value. Backdating - * removes the race without weakening what is asserted: the stamp either moved - * to ~now or it did not move at all, and those are six years apart. + * A sentinel far in the past rather than a sleep: an insert default and an + * update a few hundred microseconds later can legitimately land on the same + * stored value on any dialect. Backdating removes the race without weakening + * what is asserted: the stamp either moved to ~now or it did not move at all, + * and those are six years apart. + * + * This docblock used to record a STRONGER hazard — that on MySQL the update + * could land on an EARLIER value than the insert default, because + * `updatedAtStamp()`'s bare `knex.fn.now()` compiled to a second-precision + * `CURRENT_TIMESTAMP` against a `DATETIME(3)` column defaulted with `now(3)`. + * That was the defect #11224 fixed rather than a property of the dialect, and + * `sql-driver-11224-update-stamp-precision.test.ts` now asserts it is gone. */ const BACKDATED_MS = Date.parse('2020-01-01T00:00:00.000Z'); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 5616c44530..2e39b3914c 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -5858,32 +5858,6 @@ export class SqlDriver implements IDataDriver { return this.tablesWithTimestamps.has(object) || this.updatedAtColumnState.get(object) === 'present'; } - /** - * [#11176] The instant an UPSERT puts in `updated_at`, in the form that column - * would have DEFAULTED to on this dialect. - * - * Deliberately NOT {@link updatedAtStamp}, and the difference is one digit of - * precision on MySQL. The audit columns are `DATETIME(3)` there and - * {@link createAuditTimestampColumn} defaults them with `now(3)` for exactly - * that reason ("`CURRENT_TIMESTAMP` has to carry matching precision for a - * `DATETIME(3)` default"). `updatedAtStamp`'s bare `knex.fn.now()` compiles to - * an unqualified `CURRENT_TIMESTAMP`, which MySQL truncates to whole seconds — - * harmless on the UPDATE door it was written for, but this value also lands on - * the INSERT branch of the same statement, where today the column DEFAULT - * supplies millisecond precision. Stamping the coarser value there would make - * a freshly inserted row's `updated_at` read up to 999 ms EARLIER than its - * `created_at`: a regression on the branch this fix is not about. Matching the - * default keeps the insert branch value-identical to what `main` stores. - * - * (The UPDATE door's own truncation on MySQL is pre-existing and is left - * alone here — it is a fidelity question, not the advance-or-not question this - * card measures.) - */ - protected upsertUpdatedAtStamp(): string | Knex.Raw { - if (this.isSqlite) return new Date().toISOString(); - return this.isMysql ? this.knex.fn.now(3) : this.knex.fn.now(); - } - /** * [#11176] Put a mergeable `updated_at` in an UPSERT payload — on EVERY * dialect, not just SQLite. @@ -5918,7 +5892,7 @@ export class SqlDriver implements IDataDriver { protected stampUpsertUpdatedAt(object: string, formatted: Record): void { if (formatted.updated_at !== undefined && formatted.updated_at !== null) return; if (!this.observedUpdatedAtColumn(object)) return; - formatted.updated_at = this.upsertUpdatedAtStamp(); + formatted.updated_at = this.updatedAtStamp(); } /** @@ -5934,7 +5908,9 @@ export class SqlDriver implements IDataDriver { } /** - * The canonical instant an UPDATE stamps into `updated_at` on this dialect. + * The canonical instant a write stamps into `updated_at` on this dialect — + * for EVERY door that stamps it: {@link update}, {@link updateMany}, + * {@link rotatedUpdateById} and {@link stampUpsertUpdatedAt}. * * On SQLite (no native timestamp type) full ISO-8601 WITH an explicit `Z` — * matching the insert paths ({@link stampInsertTimestamps}) so create and @@ -5942,11 +5918,45 @@ export class SqlDriver implements IDataDriver { * `…replace('T',' ').replace('Z','')` wrote a zone-NAIVE, space-separated * string that `Date.parse` reads as LOCAL time, silently shifting the instant * by the host offset on a non-UTC runtime (the objectos freshness-probe - * miss). Postgres/MySQL keep native `now()` — a real zone-aware TIMESTAMP - * that never had the issue. + * miss). Postgres/MySQL keep a native server clock — a real zone-aware + * TIMESTAMP that never had the issue. + * + * ## [#11224] MySQL takes the precision its audit column was CREATED with + * + * {@link createAuditTimestampColumn} builds the audit columns on MySQL as + * `DATETIME(3)` defaulted with `now(3)`, and says why in as many words + * ("`CURRENT_TIMESTAMP` has to carry matching precision for a `DATETIME(3)` + * default", #3942). A bare `knex.fn.now()` compiles to an unqualified + * `CURRENT_TIMESTAMP`, which 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, a row updated inside the second it + * was created in stored `updated_at` 911 ms EARLIER than its own `created_at`. + * + * Three silent consequences, none of which errors: "last modified" precedes + * "created"; a millisecond-precision delta cursor (`updated_at > cursor`) + * SKIPS every row truncated back below it; and two updates in the same second + * are indistinguishable, so an `order by updated_at` over them is unstable. + * + * Postgres and SQLite are untouched, and that is a measurement rather than an + * assumption (`sql-driver-11224-update-stamp-precision.test.ts` §5 pins the + * emitted expression per dialect): `CURRENT_TIMESTAMP` on Postgres is + * `transaction_timestamp()` at microsecond precision against a `timestamptz` + * column, and the SQLite branch is a JS ISO-8601 string that already carries + * millis. Neither has anything to truncate. + * + * ## One helper, not two + * + * #11176 needed this exact expression for the UPSERT door — the stamp lands in + * the INSERT branch of the same statement, where a coarser value would have + * put a brand-new row's `updated_at` before its own `created_at` — and carried + * it as a separate `upsertUpdatedAtStamp()` precisely so that a card which had + * not measured the UPDATE door's emitted SQL did not change it. This card + * measured it, so the pair collapses back into this one definition and there + * is no longer a second place for the two doors to drift apart. */ protected updatedAtStamp(): string | Knex.Raw { - return this.isSqlite ? new Date().toISOString() : this.knex.fn.now(); + if (this.isSqlite) return new Date().toISOString(); + return this.isMysql ? this.knex.fn.now(3) : this.knex.fn.now(); } /**