Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): stamp updated_at when the driver never ran DDL (#11067) - #11177
Conversation
…1067) `update()` refreshed `updated_at` only for tables in `tablesWithTimestamps`, and all FOUR of that set's fill sites are downstream of DDL (the card said three; `initObjects`' rotation branch is the fourth). A `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` boot — documented behaviour, not a misconfiguration — therefore served every UPDATE with the set empty and never stamped, so `updated_at` recorded the row's creation time forever. Ships the pair: 1. `registerObjectMetadata()` records the declared-shape expectation in a new `updatedAtColumnState` map, at zero round trips. Kept apart from `tablesWithTimestamps`, which means "observed", not "inferred". 2. The first stamped UPDATE to such a table is speculative. On failure the driver asks the database (`columnInfo()`) whether `updated_at` is really absent — never the dialect's error text — and only then re-issues the caller's own statement unstamped. Any other failure rethrows the original error. Without (2), a hand-migrated table lacking the column would turn a working `update()` into a new rejection. A success proves the column exists, so one round settles the table; an absence is cached and never re-probed. When a caller transaction is open the speculative write is fenced in a SAVEPOINT via `attemptWithoutPoisoning`, because Postgres aborts the whole transaction on any statement error (#8269). The insert path (`stampInsertTimestamps`, which also writes `created_at`) and federated objects (`registerExternalObject`) are deliberately untouched. 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): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 77a07c9e4daac65d277e9f03f4709985c50940d7 && git checkout 77a07c9e4daac65d277e9f03f4709985c50940d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 10485009a693b07cf90c98e81a0d8b824c62be95 0cb8abfc625e1e0f6017bf8817a4496247c1bad7 && git checkout -B drift-repro 10485009a693b07cf90c98e81a0d8b824c62be95 && git merge --no-ff 0cb8abfc625e1e0f6017bf8817a4496247c1bad7
node scripts/docs-audit/affected-docs.mjs --json 10485009a693b07cf90c98e81a0d8b824c62be95
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11067
SqlDriver.update()refreshedupdated_atonly for tables intablesWithTimestamps, and every one of that set's fill sites is downstream of DDL. AskipSchemaSync/OS_SKIP_SCHEMA_SYNC=1deployment — documented behaviour, not a misconfiguration — therefore booted with the set empty and never stamped, soupdated_atrecorded the row's creation time forever.The fill sites: there are four, not three
Re-derived at the lines on
927ccbb23rather than taken from the card. The card names three; the rotation branch insideinitObjectsis a fourth:initObjects,createTablebranchinitObjects, "existing table already hasupdated_at" branchcolumnInfo()initObjects, rotation branch (immediately beforeensureRotation)aliasShardBookkeepingI touched none of them. They are the observations, and they stay exactly as they are — a table this driver's DDL built is never in the new speculative state at all, so the DDL path is unchanged. The fix adds a parallel
updatedAtColumnStatemap for tables the driver knows about without having run DDL against them.1 — Registration-time inference, at zero round trips
registerManagedObjectMetadata()is the one place a managed object reaches the driver on every boot posture:initObjectscalls it first and then issues DDL, and askipSchemaSyncboot (#10995'sregisterObjectMetadata()) calls it and stops. Every table this driver's own DDL creates getscreated_at/updated_atunconditionally, so registration now recordsupdatedAtColumnState[table] = 'presumed'. In-memory, no probe — the currencyskipSchemaSyncexists to save.Kept out of
tablesWithTimestampsdeliberately: that set means observed, and conflating an inference with an observation is what would make the next reader trust it too far. Registration never downgrades an answer already resolved from the database, so re-driving it on reload is idempotent.2 — How the lazy fallback distinguishes the missing-column failure
It asks the database, never the error text. The first stamped UPDATE to a
presumedtable is speculative; if it fails,resolveUpdatedAtColumnPresence()runs onecolumnInfo()— the same callinitObjectsalready uses to decide the identical fact on the DDL path — and only a definitive absent triggers recovery. Anything else rethrows the original error untouched.That matters because the three dialects spell the same failure three different ways, measured in the ablation below:
A fallback keyed to those strings would look correct and silently stop recovering on the next server upgrade. Three further properties:
null, not "absent". No columns means the table is not visible to us at all — the caller rethrows the write's own error, which names the real problem.updated_at— byte-identical to whatmainsends today.SETlist that is not there is a parse/plan error on every dialect here, whatever the row count), so success settles the table aspresent; a resolved absence settles it asabsentand is never re-probed. §3 of the pin measures this as a round-trip budget rather than claiming it.The caller's transaction survives it
On Postgres any statement error aborts the whole transaction (
25P02), so a baretry/catchwhose recovery issues SQL on the same transaction can never run there — the recovery statement is the one that raises the error you observe. Whenoptions.transactionis set, the speculative write is fenced in a knex nested transaction (aSAVEPOINT) through the existingattemptWithoutPoisoning, #8269's mechanism applied to the second speculative write in this driver. Outside a caller transaction no fence is used: knex runs the statement in its own implicit transaction, so a failure is already isolated, and a savepoint there would be a cost the flag exists to avoid.Two deliberate narrowings
stampInsertTimestampsalso writescreated_at, and none of the evidence above says anything aboutcreated_at— an UPDATE's success proves only thatupdated_atexists. It keeps readingtablesWithTimestampsexactly as before, so no new insert-path rejection is possible.registerExternalObjectdoes not route through managed registration, so a remote table is never presumed to carry audit columns.rotatedUpdateByIdshares the one decision helper but threads no fallback, and that is a property of the path rather than an omission:rotationShardsOfreturns shards only onceensureRotationhas run, and site 3 records the stronger fact on the line immediately before that call — so the presumption can never be the source there.Before/after measurement
The card's repro sketch, as a real pin (
sql-driver-timestamps-without-ddl.test.ts): out-of-bandcreate table, aSqlDriverthat never runsinitObjects,create()a row, backdate it to2020-01-01T00:00:00.000Z,update()through the driver, readupdated_atback.Backdating rather than sleeping is how "let time pass" is modelled:
knex.fn.now()compiles to MySQL'sCURRENT_TIMESTAMP, which carries no fractional digits, so an insert default ofcurrent_timestamp(3)and an update a few hundred ms later can legitimately land on the same or an earlier stored value. The sentinel removes that race without weakening the assertion — the stamp either moved to ~now or it did not move at all, and those are six years apart.Baseline, on unmodified
927ccbb23— red on all three dialects, one identical failure:1577836800000is the backdated sentinel:updated_athad not moved at all.After:
Test Files 1 passed (1) · Tests 21 passed (21).Dialects measured on
Not SQLite-only —
updated_atis dialect-dependent in the code under test (this.isSqlite ? new Date().toISOString() : this.knex.fn.now()). Every leg runs across all three cells throughdeclareDialectCell, so an unprovisioned dialect is reported rather than omitted::memory:The whole package was then run under CI's exact posture for the
Temporal Conformance (live PG + MySQL)required check — PG serverAsia/Shanghai, MySQL server+08:00, processTZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1(the three-way zone skew the matrix's non-vacuity guard demands):Ablation — the fallback leg is not vacuous
The leg that decides the tier is §2, so it was mutated rather than trusted.
updatedAtStampIsPresumed()was forced tofalse(registration-time inference left in place, fallback disabled), the mutation confirmed on disk by anchored grep counts (anchor 1→0, marker 0→1) before the run, and restored from a pristine copy by anEXIT INT TERMtrap. No rebuild is involved: the pin imports../src/index.jsby relative path, sodist/is not on the resolution path.Predicted before running — §2 and §5 red, §1/§4/§6 green. Observed:
Those §2 failures are the new rejection the pair exists to prevent — the three dialect errors quoted above are exactly what a user's working
update()would have started returning had option 1 shipped bare. Restore verified after the run (marker 0, anchor 1).Gates
Union re-run after the final commit, at
0cb8abfc6:pnpm lint(repo-wideeslint . --no-inline-config, no narrowing) ·check:type-check-debt --re-measure(33 ledger entries, none above its recorded number)check:adr-anchors(added by hand — the ADR-0120 roster onsql-driver.tsis not path-derivable) ·check:driver-conformance·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:type-check-coveragecheck:query-options-erasure·check:engine-double-contract·check:where-matcher·check:nul-bytes(the four convention-triggered by a new test file; the ratchets report "baseline key set verified against 927ccbb: no files added" — no ceiling was raised)check:changeset-gate-self-tests·check:objectui-changeset·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-ci-filter-parity·check-plugin-teardown-shape·check-affected-docspnpm --filter '...@objectstack/driver-sql' typecheck— the prefix form, dependents)Out of scope
Option 3 from the card — having the out-of-band migration path declare the fact — is a deployment-contract change and is not opened here.
Two unrelated
updated_atgaps were found while measuring this one and are filed as #11176, not fixed here:updateMany()never stamps, andupsert()'s merge branch does not advance it on Postgres/MySQL (measured on live PG 16.13, on the fully DDL-managed path).Generated by Claude Code