Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): stamp updated_at at the audit column's own precision on MySQL (#11224) - #11320
Conversation
… on MySQL (#11224) `createAuditTimestampColumn` builds the audit columns on MySQL as `DATETIME(3)` defaulted with `now(3)` (#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. The column was created at millisecond precision on purpose and then written at second precision, so a row updated inside its first second stored `updated_at` EARLIER than its own `created_at`. Measured live on MySQL 8.0.46, against the exact schema the driver produces: before CURRENT_TIMESTAMP created 10:22:36.799 updated 10:22:36.000 -799 ms after CURRENT_TIMESTAMP(3) created 10:22:36.799 updated 10:22:36.802 +3 ms The fix is the expression #11176 had already derived and measured for the UPSERT door, so the two helpers collapse back into one: `upsertUpdatedAtStamp()` is removed and `stampUpsertUpdatedAt` reads `updatedAtStamp()`, which now carries the matched precision for every door that stamps (`update`, `updateMany`, `rotatedUpdateById`, and the upsert merge). Postgres and SQLite emit byte-identical SQL, measured rather than assumed: in the baseline run against the unfixed driver those two cells were green 7/7 each and only the MySQL cell was red (6 of 7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e772039df134d998be1f59ff28a23d17e1f3c77a && git checkout e772039df134d998be1f59ff28a23d17e1f3c77a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e278a2970d2dbdb662db66dd61bc07264157fa51 c46bb3405f523bad3202042da226381838c6b1ab && git checkout -B drift-repro e278a2970d2dbdb662db66dd61bc07264157fa51 && git merge --no-ff c46bb3405f523bad3202042da226381838c6b1ab
node scripts/docs-audit/affected-docs.mjs --json e278a2970d2dbdb662db66dd61bc07264157fa51
|
os-zhuang
commented
Aug 23, 2026
PM review note (engine seat) — not a blocker; CI is still running and I will re-read the required jobs by name when it settles. Verified independently: the removal is real, and the |
os-zhuang
commented
Aug 23, 2026
Docs Drift Check — chased, clean. No doc edit owed. Recording the disposition so it is not re-derived; this bot has produced real findings for this seat before (#11205 invalidated four shipped claims), so "advisory" is not a reason to skip it. All seven rows entered via the coarse
⛔ The release-owned row is read-only and stays that way. For the record, it is also not wrong: Generated by Claude Code |
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review and enqueued. Green read by job name, not by aggregateAll 34 check runs completed. Every one The jobs this card actually rests on:
|
Fixes#11224
What was wrong
createAuditTimestampColumnbuilds the audit columns on MySQL asDATETIME(3)defaulted with
now(3), and its docblock states the requirement in as many words("
CURRENT_TIMESTAMPhas to carry matching precision for aDATETIME(3)default",#3942).
updatedAtStamp()— the value every UPDATE door writes into that samecolumn — was a bare
knex.fn.now(), which compiles to an unqualifiedCURRENT_TIMESTAMPthat MySQL truncates to whole seconds.The column was created at millisecond precision on purpose and then written at second
precision.
Measured live, MySQL 8.0.46, against the exact DDL the driver emits
Raw SQL, both forms side by side on one table built exactly as
createAuditTimestampColumnbuilds it (datetime(3) default current_timestamp(3)):And through the real driver,
new SqlDriver(...)→initObjects→create()→update(). The failing baseline was demonstrated first, by running this PR's newsuite against the unmodified driver:
6 of 21 red, and every one of them on the live MySQL cell. The SQLite cell and the
live Postgres cell were green 7/7 each — the dialect asymmetry observed, not argued.
The fix
This is not a fresh derivation. #11176 had already derived and measured the correct
expression for the UPSERT door and left a follow-up plan in the record: "the two
collapse into one helper when #11224 lands." So:
updatedAtStamp()becomesnow(3)on MySQL, unchanged on Postgres and SQLite.upsertUpdatedAtStamp()is removed andstampUpsertUpdatedAtreadsupdatedAtStamp(). The pair has collapsed into one helper — answer to "did theyactually collapse": yes, one definition, zero remaining forks.
All three UPDATE doors read that single helper (
update,updateMany,rotatedUpdateById), so they move together and cannot drift apart again.What the other dialects do — measured, not assumed
CURRENT_TIMESTAMP(3)DATETIME(3) default now(3)CURRENT_TIMESTAMPtransaction_timestamp(), microsecond precision,timestamptzcolumn — nothing to truncateZBoth statements are backed by two independent measurements: the Postgres and SQLite
cells were green in the baseline run and in the fixed run, and §5 of the new suite
pins which expression each dialect gets — so a future "just add
(3)everywhere"cannot satisfy the ordering assertions while silently changing the SQL Postgres and
SQLite emit.
Tests
packages/drivers/driver-sql/src/sql-driver-11224-update-stamp-precision.test.ts, runon SQLite + live Postgres 16 + live MySQL 8.0.46 through
declareDialectCell, so anunprovisioned dialect is reported and never silently omitted.
updated_at >= created_at, on a row updatedinside the second it was created in, through
update()and throughupdateMany().This is the property a delta cursor depends on, asserted instead of a string shape.
type —
where updated_at >= :cursor, bound to the value the row itself stored —because that is where an incremental sync makes the comparison. Comparing two JS
numbers here would have measured this test's parsing instead.
stampUpsertUpdatedAtputs inits payload must equal what
updatedAtStamp()returns, character for character. Redbefore this PR on MySQL (
CURRENT_TIMESTAMP(3)vsCURRENT_TIMESTAMP); it stays redif anyone re-forks the two helpers.
preserveAudithistorical import still wins over the stamp.Non-vacuity. Truncation is only observable on a
created_atthat carried a non-zeromillisecond component. Every ordering cell runs 6 independent create+update pairs and
asserts at least one carried sub-second digits — otherwise a run in which every
default happened to land on
.000would report a green that no truncation could haveperturbed. §3 additionally asserts its own span stayed under one second, since a run that
straddled a second boundary could have distinguished the stamps at second precision too.
Verification, with the exit captured before any pipe
Tests 6 failed | 15 passed (21)— all 6 on the MySQL celldriver-sqlsuite, live PG + MySQL,TZ=America/New_YorkTest Files 121 passed (121)·Tests 2488 passed (2488)turbo run typecheck --filter='...@objectstack/driver-sql'— 48 packages, all greenpnpm lint(repo-wideeslint . --no-inline-config)The live legs ran with the servers skewed exactly as CI's `Temporal Conformance (live PG
job skews them — MySQL@@global.time_zone = '+08:00', Postgrestimezone='Asia/Shanghai', processTZ=America/New_York` — so the three-way zone skewthe matrix requires was real and not a UTC-on-UTC pass.
The downstream direction is the prefix form
...@objectstack/driver-sql(the packageplus its 47 dependents), which is the direction a contract tightening actually travels.
One package,
@objectstack/plugin-auth, first reportedTS7016: Could not find a declaration file for module '@objectstack/plugin-auth'in its ownexamples/basic-usage.ts— a cold-worktree race between itsbuild's DTS step and itsown
typecheck, i.e. a module-resolution artifact that is NOT MEASURED, not afailure. Built alone and re-run, it is green:
tsc --noEmit && tsc --noEmit -p tsconfig.examples.json, exit 0.Gates
node scripts/pm/dispatch-gates.mjs(no paths passed — it derived the change set itselffrom the merge base) named 13 path-derived families plus 6 convention-triggered ones.
All were run, plus
check:adr-anchorsby hand (this file carries an ADR-0120 anchorroster the derivation does not select) and
check:nul-bytes. Every one exit 0:The ratchet family was re-run on the final commit and reports against it:
c46bb3405— e.g.✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new,✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new,✓ where-matcher conformance holds: 283 matcher(s) discovered, 283 answer the combinator battery correctly or refuse it loudly. No ceiling was raised andno test is skipped, disabled or quarantined.
check:type-check-debtwas run against a fully built workspace(
turbo run build --filter=./packages/* --filter=./packages/*/*, exit 0 first), so its--re-measureverdict is a measurement rather than a refusal:OK — 33 ledger entr(ies) re-measured in 325.1s, 1897 raw tsc error(s) total, none above its recorded number.On-hold neighbours in this file — counted, with a positive control
Neither
pm:on-holdcard's restart condition is touched. Counting the symbols across thechanged lines of this diff:
A zero is only a reading if the pattern can find these symbols at all, so the positive
control — the same literals over
sql-driver.tsas it stands after this PR:…and a second control proving the diff filter itself matches: feeding it a synthetic
+ this.insertOnlyUpsertColumns(object);line returns1. So the three zeros above aremeasurements, not an unarmed grep.
Serialization with #11270
This PR stays entirely out of
sql-driver.ts's introspection region, which PR #11270(#11122, spec seat) is rewriting. This work is the write-door stamp —
updatedAtStamp()and its single upsert call site — and the two sit far apart with no semantic overlap.
packages/specandschema-drift.tsare untouched here. Branched fromf24c90df3; if#11270 lands first this rebases rather than fights it.
Two bounded in-place repairs, named here rather than left silent
Removing
upsertUpdatedAtStamp()left three docblocks describing driver behaviour thatis no longer true. Each is a comment in the same package about the exact expression this
PR changed, mechanically determined by the change itself, with no other claim on the
files:
sql-driver-11176-bulk-and-merge-updated-at.test.ts§4's head note namedupsertUpdatedAtStamp()as the fix — now a symbol that does not exist. Rewritten torecord that the second helper was driver-sql:
updateMany()never stampsupdated_at, andupsert()'s merge branch does not advance it on Postgres/MySQL — on every deployment, DDL or not #11176's deliberate split and that driver-sql: on MySQL the UPDATE door'supdated_atstamp truncates to whole seconds against aDATETIME(3)column — a row updated in its first second readsupdated_atEARLIER thancreated_at#11224 collapsedit.
BACKDATED_MSdocblock justified backdating by "MySQL's unqualifiedCURRENT_TIMESTAMPcarries no fractional digits". True ofmain, false as of thiscommit. Backdating is still right — an insert default and a stamp taken a moment later
can still collide — but the window is now sub-millisecond, and the rationale says so.
sql-driver-timestamps-without-ddl.test.ts'sBACKDATED_MSdocblock made the strongerclaim that the update "can legitimately land on … an earlier stored value". That
was the defect, recorded as a property of the dialect. Rewritten to say it was a
defect and that this PR's suite now asserts it is gone.
No assertion, fixture or executed line was changed in either test file — the edits are
comment-only, and both files pass unchanged (72/72 across the three files, all dialects).
Release
minor, per this repo's launch-window convention for breaking changes — notmajor,and
content/docs/releases/is untouched. The break is narrow and named out loud in thechangeset: the
protectedupsertUpdatedAtStamp()that shipped in 17.2.0 is removed, soa
SqlDriversubclass that overrode it would otherwise keep compiling while silentlyceasing to be called. Such a subclass should override
updatedAtStamp()instead. The twoin-repo subclasses,
SqliteWasmDriverandTursoDriver, override neither and areunaffected (both are SQLite-family, so they take the unchanged branch). Stored data is not
rewritten: rows updated before this change keep their truncated stamp, and the ordering
invariant holds from the next write onward.
Generated by Claude Code