Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): give the SQLite builtin audit columns the canonical ISO-8601 DEFAULT - #11401
Conversation
…ISO-8601 DEFAULT
`createAuditTimestampColumn`'s non-MySQL branch was
`table.timestamp(name).defaultTo(this.knex.fn.now())`, which on SQLite compiles
to an unqualified `CURRENT_TIMESTAMP` — a zone-naive, space-separated,
second-precision string. A declared `Field.datetime` with `defaultValue:
'NOW()'` in the SAME table already got the canonical ISO-8601 form from
`nowColumnDefault`, so one table carried two spellings of one conceptual value.
Route the SQLite branch through `nowColumnDefault('datetime')` — the existing
single source for "what does NOW() mean in DDL on this dialect" — rather than
restating the expression, so the two cannot drift apart again.
`rebuildSqliteTablePatched` carried the same `knex.fn.now()` for the audit
columns. That method is SQLite-only, so leaving it would have silently REVERTED
a canonically-created table the moment any unrelated drift triggered a rebuild.
Fixed together; a rebuild must hand back the column `initObjects` would build.
Postgres is untouched (`knex.fn.now()` there is a real zone-aware TIMESTAMP);
MySQL keeps `now(3)` from #11224.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y…drift's indifference to it Six legs on real better-sqlite3 through the driver's own initObjects: 1. emitted DDL — audit columns and a declared Field.datetime NOW() sibling carry the SAME default expression (compared to the sibling, not to a literal, so a future respelling moves both sides together); 2. a DEFAULT-firing insert stores a canonical instant in every column; 3. the blast-radius question — a table still carrying the OLD CURRENT_TIMESTAMP default reports NO drift, WITH a positive control proving the default-reading dimension is live in the same call; 4. a drift-triggered SQLite rebuild hands back the canonical default; 5. on a skipSchemaSync boot the driver's own create() door — which reaches the column DEFAULT because tablesWithTimestamps is empty — now stores canonical; 6. dialect gate: Postgres keeps CURRENT_TIMESTAMP, MySQL keeps CURRENT_TIMESTAMP(3), neither leaks strftime. Compiled offline. 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 2f80ff3bdb0ca7aeacb0ecdad70a53ecd93c04aa && git checkout 2f80ff3bdb0ca7aeacb0ecdad70a53ecd93c04aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 365e3340f5c7807eb0f59af115f93476e508a094 1bf96b8a699d3ab1a281ccf6898b5e7ef0fdfabc && git checkout -B drift-repro 365e3340f5c7807eb0f59af115f93476e508a094 && git merge --no-ff 1bf96b8a699d3ab1a281ccf6898b5e7ef0fdfabc
node scripts/docs-audit/affected-docs.mjs --json 365e3340f5c7807eb0f59af115f93476e508a094
|
os-zhuang
commented
Aug 23, 2026
Docs Drift Check — chased, no edit owed. Recording the disposition so a reviewer doesn't re-derive it. This bot has produced real findings for this seat (#11205 invalidated four shipped claims), so "advisory" is not grounds to skip it — and unlike the last two driver-sql PRs, this one changes DDL, so a doc stating what the audit columns default to would genuinely be falsified. One page does discuss SQLite datetime defaults and explicitly contrasts them with a bare
That sentence was true before this PR and stays true after — it is about declared fields, which already had the canonical form. So the row is a coarse Two things worth stating rather than filing. First, that page's own Callout already documents the exact disposition this PR relies on for existing tables:
The PR reached that same posture independently via Second, and the reason this is not purely a miss: the section opens by saying the driver resolves Generated by Claude Code |
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: Green read by job name34 check runs, 0 still running, none non-green; The gating question was answered the right wayI made one question blocking before any edit: does schema-drift read column defaults? — because if it did, this narrow correctness fix would make every already-deployed SQLite database start reporting drift on Answered no, and answered as a measurement rather than a code reading alone. That is the difference between "I looked and saw nothing" and "I proved the instrument was live and it saw nothing." The second site — accepted region extension, and the PR would have been worthless without it
Reachability was measured, not inherited — and the card was understatedI asked for this explicitly because this seat has twice today published an inherited scope claim dressed as a measurement. The card said the DEFAULT only fires on writes that bypass the driver. Measured, the driver's own The declared Smaller things that were right
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11321
On SQLite the builtin
created_at/updated_ataudit columns defaulted to an unqualifiedCURRENT_TIMESTAMP— zone-naive, space-separated, second-precision — while a declaredField.datetimewithdefaultValue: 'NOW()'in the same table already got the canonical ISO-8601 form. One table, two spellings of one conceptual value.The SQLite branch of
createAuditTimestampColumnnow routes throughnowColumnDefault('datetime'), the existing single source for "what does NOW() mean in DDL on this dialect", rather than restating the expression — so the two cannot drift apart again. Postgres and MySQL are untouched (knex.fn.now()on Postgres is a real zone-awareTIMESTAMP; MySQL keeps thenow(3)precision match from #11224).The question that gates this card: does schema-drift read column defaults?
Answered no, by reading the code and by measuring on live in-memory SQLite (better-sqlite3) through the real
detectManagedDriftentry point, against a table carrying the OLD default — i.e. exactly what every already-deployed SQLite database holds.The zero is positive-controlled, because a bare empty array is equally satisfied by an inert harness. Same table, same call, after adding an orphan column and a
current_userdefault:default_mismatch— the drift kind that does read column defaults — fires in the same call, and still says nothing about the audit columns. Two independent guards explain it:BUILTIN_COLUMNSskipscreated_at/updated_atin both ofdiffManagedTable's loops, and the onlydefault_mismatchproducer is the #4560 runtime-token check, for whichisAppResolvedDefaultToken('NOW()')is pinnedfalseinpackages/spec.applyDeclaredColumnDefault's own docblock states the same invariant independently: "there is no general declared-literal-vs-physical comparison".So no existing SQLite deployment starts reporting drift on its audit columns. Both call sites are
CREATE TABLEonly —initObjects'alterTablebranch adds declared fields and never the audit columns — so existing tables keep their old default, andformatOutput'srepairNaiveUtcAuditTimestampfolds already-written naive rows to canonical on read. That is the same dispositionnowColumnDefaultalready documents for declared fields.A second site, named here because it is not in the card
rebuildSqliteTablePatched— the whole-table rebuild SQLite drift reconciliation uses — re-emitted the audit default itself, asknex.fn.now():That method runs only under
if (this.isSqlite)inapplyMigrationEntries. Fixing onlycreateAuditTimestampColumnwould therefore have shipped a fix that silently reverts itself: a canonically-created table would fall back toCURRENT_TIMESTAMPthe first time any unrelated drift (a relaxed NOT NULL, an orphaned column) triggered a rebuild. Fixed in the same change, and pinned by §4 — whose reverse-verification leg (below) turns red alone, so it is a real guard and not an untested rider. This extends the card's declared region beyondcreateAuditTimestampColumn(~12452) and its call sites; it is disjoint from #11324'sintrospectForeignKeysregion and from bothpm:on-holdsymbol sets.Reachability — measured, and wider than the card states
The card says the DEFAULT only fires on writes that bypass the driver. Measured, that is not the whole population: the driver's own
create()door reaches it on a documented deployment posture.stampInsertTimestampsgates ontablesWithTimestamps, which only DDL-running paths populate. OnskipSchemaSync/OS_SKIP_SCHEMA_SYNC=1,registerObjectMetadata(the DDL-free registration door) deliberately setsupdatedAtColumnState='presumed'and never touches that set — so it is empty, the stamp returns early, and the insert falls through to the column DEFAULT. One table, one row per boot posture, before the fix:The declared NOW() sibling is canonical on both postures — because its canonical shape lives in the column DEFAULT rather than in an app-side stamp. That asymmetry is the argument for fixing this in DDL, and §5 pins it closed.
Existing pins on the old DDL
None. A repo-wide sweep for
CURRENT_TIMESTAMPfound no test asserting the SQLite audit-column DDL. The one dialect-gate assertion (sql-driver-user-datetime-default-format.test.ts:202) covers Postgres/MySQL only. Nothing was rewritten to match new behaviour.The opposite is true and worth stating: two live-matrix suites already spell the SQLite audit default as
strftime('%Y-%m-%dT%H:%M:%fZ','now')in their hand-written-migration fixtures, each with a docblock claiming those columns take "the SAME physical typescreateAuditTimestampColumnproduces" —sql-driver-11176-bulk-and-merge-updated-at.test.tsandsql-driver-timestamps-without-ddl.test.ts. That claim held on MySQL and was false on SQLite; the fixtures modelled a more correct table than the driver built. This change makes the driver agree with them.Tests
packages/drivers/driver-sql/src/sql-driver-11321-sqlite-audit-default-canonical.test.ts— six legs on real better-sqlite3 through the driver's owninitObjects. §1 and §2 assert the audit columns agree with the declared NOW() sibling in the same table rather than matching a hard-coded literal, so a future respelling of the canonical expression moves both sides together and still fails if only one moves.Reverse verification — direction predicted before each leg, then observed
createAuditTimestampColumnbeforebecomesCURRENT_TIMESTAMPwhile the still-fixed rebuild returns canonical, soafter === beforefails); §3 §6 greenknex.fn.now()inrebuildSqliteTablePatchedonly§3 staying green under leg A is the point of that prediction: its subject is drift's indifference to the default, which the fix never changed.
Each leg proved the mutation on disk at the text it meant to change (
createAuditTimestampColumncanonical line 1→0, 119 bytes removed for A; rebuild canonical 1→0 andknex.fn.now0→1 for B), and each restore was proved byte-identical bygit hash-objectagainst the HEAD blob, not by an insertion count. Both mutation scripts installed a bashtrapon EXIT, INT and TERM whose action was thegit checkout HEAD --restore, so a foreground-cap SIGTERM landing mid-mutation cannot leave the tree mutated. No build/dist step is involved: the suite imports../src/index.js, a relative path vitest compiles from source, so the mutation is in the code under test with nothing to rebuild.Gates
Union derived from the actual diff via
node scripts/pm/dispatch-gates.mjswith no paths passed, re-derived unchanged at the final commit1bf96b8a6. All 20 green, each exit status captured before any pipe:changeset-gate-self-tests0 ·driver-conformance0 ·objectui-changeset0 ·published-files0 ·slot-lookup0 ·test-source-alias0 ·type-source-resolution0 ·adr-0087-registration0 ·changeset-no-major0 ·ci-filter-parity0 ·empty-changeset0 ·plugin-teardown-shape0 ·affected-docs0 ·query-options-erasure0 ·type-check-coverage0 ·type-check-debt0 ·engine-double-contract0 ·cross-package-test-inputs0 ·where-matcher0 ·nul-bytes0check:type-check-debtran against a built workspace closure and printed its own verdict: "--re-measure: OK — 33 ledger entr(ies) re-measured in 278.4s, 1897 raw tsc error(s) total, none above its recorded number."Package-level, at the same commit:
pnpm --filter @objectstack/driver-sql typecheckclean (tsc --noEmit, script name echoed), and the full suite 116 files passed | 7 skipped, 1854 tests passed | 92 skipped — the skips are the live-dialect PG/MySQL cells, which CI runs.Repo-wide
pnpm lint(eslint . --no-inline-config) run in full — exit 0 in 99s. Not a narrowing.Deliberately not done
backfillCanonicalDatetimes/sqliteCanonicalDatetimeSqluntouched — thepm:on-holddrivers(sql): 共享 canonical 修复表达式把「裸数字文本」当儒略日读,backfill 会把这个误读写进盘里(观察项) #6009 symbols. Measured over my changed lines rather than asserted: 0 occurrences of all three on-hold symbols (drivers(sql): 共享 canonical 修复表达式把「裸数字文本」当儒略日读,backfill 会把这个误读写进盘里(观察项) #6009's two plus drivers(sql):upsert's empty-merge-set fallback is merge-ALL, which re-admits every insert-only column — currently unreachable, but it is the wrong shape for "nothing to merge" #8740'sinsertOnlyUpsertColumns) across 50 added lines. Positive-controlled both ways — the same literals appear 2/10/7 times in the whole file, and a synthetic added line fed to the same filter is caught 1/1/1. The first version of that filter was broken: its exclusion pattern for diff headers was written in basic regex, where an escaped plus is a repetition operator rather than a literal, so it stripped every added line and reported a false 0. The controls caught it, and the numbers above are from the repaired instrument.Fixes, notPart of— the card's acceptance criterion is fully met, including the second site that would have undone it.Generated by Claude Code