test(driver-sql): pin how the record read door materialises updated_at on live PG and MySQL - #13971
Queued
claude[bot] wants to merge 1 commit into
Queued
test(driver-sql): pin how the record read door materialises updated_at on live PG and MySQL#13971claude[bot] wants to merge 1 commit into
claude[bot] wants to merge 1 commit into
Conversation
…updated_at
The record-data OCC gate compared `updated_at` against the caller's token with
`String(v)` on both sides. On Postgres — the production default driver — that
value arrives as a JS `Date`, so the comparison ran a
`Date.prototype.toString()` spelling against the ISO text the client echoed
back: one instant, two spellings, and every guarded save answered 409 on
records nobody had touched.
It survived because every existing OCC pin drives ISO text on BOTH sides
(memory, a mocked engine, or SQLite — all round-trip canonical ISO). The
discriminating input is the driver's `Date`, and nothing in the repo ever
produced it into that seam. `@objectstack/metadata-protocol` has no driver
dependency and must not grow one, so the seam's own regression suite
necessarily drives a hand-made `Date`; the composed fact — this driver, on
these dialects, hands a `Date` out of its record read door — was asserted by
nothing.
This pins it where the live servers already are:
§A the JavaScript half, server-free and therefore measurable on every
runner: `Date.prototype.toString` renders whole seconds in the PROCESS
zone while `toISOString` renders milliseconds in UTC. The process zone is
FORCED rather than required, so the cell is non-vacuous at UTC too.
§B the dialect half, through `findOne` — the door `probeRecord` reads:
`updated_at` is a JS `Date` on live Postgres/MySQL and canonical ISO-8601-Z
TEXT on SQLite, the accident that kept every OCC pin green.
Not over-pinning: `withPostgresCalendarDayAsText` states the decision in as
many words ("`timestamptz` / `timestamp` are deliberately untouched: those are
instants, a `Date` is the right materialisation for them").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
zhuangjianguo
marked this pull request as ready for review
August 31, 2026 20:55
zhuangjianguo
added this pull request to the merge queue
Aug 31, 2026
Any commits made after this event will not be merged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13567
Adds the driver-side characterisation pin the card recommends as option 1 — and only option 1. One new test file in
@objectstack/driver-sql, a package already insideTemporal Conformance (live PG + MySQL). Nothing else is touched: nopackages/metadata-protocol(the layering runs the other way, and PR #13870 is in flight inprotocol.ts), no new package in any required job's set.What was uncovered, and what is pinned now
The record-data OCC gate compared a record's
updated_atagainst the caller's token withString(v)on both sides. On Postgres — the production default driver — that value arrives as a JSDate, so the comparison ran aDate.prototype.toString()spelling against the ISO text the client echoed back from its own GET. One instant, two spellings, strict string compare: every guarded save answered 409 on records nobody had touched.It survived because every existing OCC pin drives ISO text on both sides — memory, a mocked engine, or SQLite, all of which round-trip canonical ISO. The discriminating input is the driver's
Date, and nothing in the repo ever produced it into that seam. The seam's own regression suite necessarily drives a hand-madeDate, because@objectstack/metadata-protocolhas no driver dependency and must not grow one. The composed fact — this driver, on these dialects, hands aDateout of its record read door — was asserted by nothing.packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.tsasserts it, in two halves that rest on different evidence.Which half rests on which evidence
Section A — the JavaScript half. Measured locally, on this runner.
Date.prototype.toStringrenders whole seconds in the PROCESS zone;toISOStringrenders milliseconds in UTC. This is a property of ES2018'sToDateString, not of any server, so §A runs on every runner including Test Core. It was verified under Node 22 across five zones before it was written, and it reproduces the incident's own spelling byte for byte: underAsia/Shanghai,2026-08-30T10:19:25.947ZspellsSun Aug 30 2026 18:19:25 GMT+0800 (…). The process zone is FORCED by the cell rather than required of the runner, so §A is non-vacuous at UTC too — and it asserts the zone dependence by DIFFERENCE (oneDate, four process zones, four distinct spellings) rather than against any literal offset, so no tzdata value is pinned.Section B — the dialect half. Only a live server can answer it, so it is NOT MEASURED here. What
SqlDriver#findOne— the doorprobeRecordreads through — puts inupdated_at. The claim rests on four mechanical readings of this tree, not on a guess about what Postgres returns:createAuditTimestampColumnbuilds the audit columns withtable.timestamp(name)on Postgres andtable.datetime(name, { precision: 3 })on MySQL.useTztotrue, sotable.timestampemitstimestamptz.sql-driver-datetime-postgres-timezone.test.tsalready pins that data type for a declaredField.datetimeon a live server.pg-typesregisters OID 1184 (timestamp with time zone) toparseDate, which returns a JSDate;withPostgresCalendarDayAsTextoverrides only OID 1082 / 1182 and states why in as many words — "timestamptz/timestampare deliberately untouched: those are instants, aDateis the right materialisation for them, andField.datetimedepends on it."sql-driver-11389-date-tz-skew.test.tsalready assertsrow.ts instanceof Datefor atimestamptzon the live PG cell.dateStringstofalseand routesDATETIME/TIMESTAMPthroughpacket.parseDateTime(timezone), which returnsnew Date(...);withUtcSessionpinsconnection.timezoneto'Z', which is what makes thatDatethe right instant rather than a host-zone one. Read out of the installedmysql23.23.3 source in this worktree, not from memory.formatOutputrepairscreated_at/updated_atonly inside itsif (this.isSqlite)arm (repairNaiveUtcAuditTimestampoverAUDIT_TIMESTAMP_COLUMNS), and the audit columns are BUILTIN, so no declared-field coercion (datetimeFields,dateFields,timeFields,booleanFields) reaches them on the live dialects.MySQL and Postgres are asserted with the same claim because the reading above establishes it for both by two independent mechanisms. Nothing dialect-specific beyond "is a
Date" is asserted, and the two properties the card names — milliseconds lost, process zone carried — are asserted as properties of the rendering, which is JavaScript, not of either server.Zone-2 verdicts
updated_atback and assert on it? Partly, and never on its TYPE.sql-driver-11224-update-stamp-precision.test.tsreads the audit columns on all three cells, but through raw knex ("past every read-side coercion") and through anasInstanthelper that accepts aDate, a number OR a string — so it is type-tolerant by construction and cannot discriminate. Same shape insql-driver-11176-...,sql-driver-11223-...andsql-driver-timestamps-without-ddl.test.ts. The new pin reads throughfindOneinstead, which is the door the seam probes.declareDialectCell(cell, matrix, measure)fromlive-dialect-matrix.testkit.ts, iterated overDIALECT_CELLS. The new file uses exactly that, so an unprovisioned cell is a NAMED SKIP locally and a named RED underOS_EXPECT_LIVE_DIALECT_MATRIX=1. Verified by running it both ways (see below). Per-file schema isolation and the globalSetup that creates the schemas both derive from a directory listing, so the new file needs no registration anywhere.Date? Falsified — it does not. This was the STOP condition, and it does not fire.withPostgresCalendarDayAsTexttouchesdate/date[]only and says so;formatOutput's audit-timestamp repair and its datetime normalisation are both insideif (this.isSqlite). On the live dialectsupdated_atreaches the caller as the client materialised it. Confirmed positively from the other side too: the SQLite cell runs locally and observes ISO TEXT, which is exactly the asymmetry — if some shared read-path normaliser existed, the sqlite and live cells could not disagree.driver-sqlalready carry a test that would have caught it? No. Repo-wide, the onlyupdated_attype assertions are inpackages/services/service-messaging, against an OUTBOX insert payload, not a driver read-back. The nearest thing in this package issql-driver-11389-date-tz-skew.test.ts, which assertstimestamptz instanceof Dateon the live PG cell — but for an ad-hoc SQL literal, PG only, and not for the audit column the OCC seam reads. The gap is real and as wide as the card says.What executed, and what did not
Temporal Conformancein CI is its first real execution. This environment has noOS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URL, nothing on 5432 or 3306, and no docker daemon.TZ=America/New_York(the job's zone)OS_EXPECT_LIVE_DIALECT_MATRIX=1, no URLsdriver-sqlsuite,TZ=America/New_Yorktsc --noEmit --listFiles(driver-sql)pnpm lint(repo-wideeslint . --no-inline-config)All of the above at
aee4857c83, which is this branch's head.Ablation — the pin can fail, and it was shown twice
Both legs mutate the file on disk, prove the mutation landed, run, then restore under
trap … EXIT INT TERMwith absolute paths and prove the restore.updated_at instanceof Date)Date, so the type assertion discriminatesString(v)is NOT the echoed token)Evidence for each leg: the removed anchor counted 1 before and 0 after, the injected anchor 0 before and 1 after, and
git hash-objectmoved off the HEAD blob (6640b2b1…to39893824…and1bb3d853…). Restore proven by observation, not by an exit code: the blob returned to6640b2b1…andgit diff HEADcame back empty on both legs.Tests N failedline. The numbers above are from the corrected run.Also worth recording: §A2's UTC cell went red on its own first run, on
Object.is(-0, 0)being false — the same negative-zero hazardlive-dialect-matrix.testkit.ts's owneastOfUtcrecords, found here by running rather than by sabotage. Fixed in the file with the reason written down.The two live cells cannot execute here, so their ablation is NOT MEASURED and no claim is made about one.
Gates — green, separated from NOT MEASURED
Derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(which reads the change set from the merge base itself), then every derived family run with the exit code captured before any pipe. 26 families, 23 green:check:ci-filter-parity·check:comment-mask-adoption·check:cross-package-test-inputs(both spellings) ·check:keyed-text-bounds·check:plugin-teardown-shape·check:shard-attestation·check:undeclared-dep-imports·check:affected-docs·check:drift-comment·check:doc-authoring·check:driver-conformance·check:engine-double-contract·check:logger-receiver-detach·check:objectql-double-limit·check:page-declaration-shape·check:published-files·check:query-options-erasure·check:slot-lookup·check:test-source-alias·check:type-check-coverage·check:type-source-resolution·check:where-matcher— all exit 0. Pluscheck:nul-bytes(7661 files, clean) and a direct control-byte sweep of the new file.NOT MEASURED — three prerequisite refusals, none of them a pass and none a finding:
node scripts/check-test-completeness.mjs— exit 3, its own words: "Nothing was measured: this gate exited before parsing a single summary line." It needs a savedturbo run testlog.pnpm check:dual-build-cjs-loads— exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/", naming 78 packages. Needs a fullpnpm build.pnpm check:type-check-debt— exit 1, but it is a REFUSAL, not a ratchet failure: its self-test printed47 semantic case(s) + … holdand the structural half printedcheck-type-check-coverage: OK, then--re-measurethrew "cannot run: 50 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk". Two independent reasons this diff cannot have moved that ratchet:@objectstack/driver-sqlappears in neither theDEBTnor theTEST_DEBTledger, so--re-measurehas no entry for it; and the STRUCTURAL half — the one that would demand a newTEST_DEBTentry for a package hiding its tests from tsc — ran and passed, consistent withdriver-sql's tsconfig includingsrc/**/*and the--listFilesreading above. Stated as an argument, not as a measurement.No ratchet counted UP. No baseline was updated, and no gate's
--updatewas invoked.Clause ② self-declaration, from the actual diff
packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts. Nothing underpackages/spec/src/**.git diff --statagainst the merge base: 1 file changed, 362 insertions, 0 deletions.Both limbs read NO, so per the 不预挂 rule no
needs:contract-reviewlabel is attached.Changeset
None, and
skip-changesetis applied: the diff publishes nothing from any package — one test file, no source, no docs, no spec.Scope
Option 1 only. If this pin turns out to be insufficient, widening the required job's package set is option 2 and a maintainer decision, not a patch — so nothing here reaches for it.
Generated by Claude Code