Skip to content

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
mainfrom
claude/issue-13567-live-pg-updated-at-characterisation
Queued

test(driver-sql): pin how the record read door materialises updated_at on live PG and MySQL#13971
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-13567-live-pg-updated-at-characterisation

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 inside Temporal Conformance (live PG + MySQL). Nothing else is touched: no packages/metadata-protocol (the layering runs the other way, and PR #13870 is in flight in protocol.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_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 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-made Date, because @objectstack/metadata-protocol has no driver dependency and must not grow one. The composed fact — this driver, on these dialects, hands a Date out of its record read door — was asserted by nothing.

packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts asserts 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.toString renders whole seconds in the PROCESS zone; toISOString renders milliseconds in UTC. This is a property of ES2018's ToDateString, 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: under Asia/Shanghai, 2026-08-30T10:19:25.947Z spells Sun 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 (one Date, 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 door probeRecord reads through — puts in updated_at. The claim rests on four mechanical readings of this tree, not on a guess about what Postgres returns:

  • createAuditTimestampColumn builds the audit columns with table.timestamp(name) on Postgres and table.datetime(name, { precision: 3 }) on MySQL.
  • knex's pg column compiler defaults useTz to true, so table.timestamp emits timestamptz. sql-driver-datetime-postgres-timezone.test.ts already pins that data type for a declared Field.datetime on a live server.
  • pg-types registers OID 1184 (timestamp with time zone) to parseDate, which returns a JS Date; withPostgresCalendarDayAsText overrides only OID 1082 / 1182 and states why in as many words — "timestamptz / timestamp are deliberately untouched: those are instants, a Date is the right materialisation for them, and Field.datetime depends on it." sql-driver-11389-date-tz-skew.test.ts already asserts row.ts instanceof Date for a timestamptz on the live PG cell.
  • mysql2 defaults dateStrings to false and routes DATETIME/TIMESTAMP through packet.parseDateTime(timezone), which returns new Date(...); withUtcSession pins connection.timezone to 'Z', which is what makes that Date the right instant rather than a host-zone one. Read out of the installed mysql2 3.23.3 source in this worktree, not from memory.
  • formatOutput repairs created_at / updated_at only inside its if (this.isSqlite) arm (repairNaiveUtcAuditTimestamp over AUDIT_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

  • A2.1 — does a live cell already read updated_at back and assert on it? Partly, and never on its TYPE. sql-driver-11224-update-stamp-precision.test.ts reads the audit columns on all three cells, but through raw knex ("past every read-side coercion") and through an asInstant helper that accepts a Date, a number OR a string — so it is type-tolerant by construction and cannot discriminate. Same shape in sql-driver-11176-..., sql-driver-11223-... and sql-driver-timestamps-without-ddl.test.ts. The new pin reads through findOne instead, which is the door the seam probes.
  • A2.2 — how do live cells gate on availability? declareDialectCell(cell, matrix, measure) from live-dialect-matrix.testkit.ts, iterated over DIALECT_CELLS. The new file uses exactly that, so an unprovisioned cell is a NAMED SKIP locally and a named RED under OS_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.
  • A2.3 — does the read path normalise before the test can see the Date? Falsified — it does not. This was the STOP condition, and it does not fire. withPostgresCalendarDayAsText touches date / date[] only and says so; formatOutput's audit-timestamp repair and its datetime normalisation are both inside if (this.isSqlite). On the live dialects updated_at reaches 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.
  • A2.4 — does driver-sql already carry a test that would have caught it? No. Repo-wide, the only updated_at type assertions are in packages/services/service-messaging, against an OUTBOX insert payload, not a driver read-back. The nearest thing in this package is sql-driver-11389-date-tz-skew.test.ts, which asserts timestamptz instanceof Date on 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

⚠️ The live cells did not execute. This pin is NOT MEASURED locally, and Temporal Conformance in CI is its first real execution. This environment has no OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL, nothing on 5432 or 3306, and no docker daemon.

run result
new file, ambient UTC 6 passed, 2 named-skipped (live postgres, live mysql)
new file, TZ=America/New_York (the job's zone) 6 passed, 2 named-skipped
new file, OS_EXPECT_LIVE_DIALECT_MATRIX=1, no URLs 6 passed, 2 named RED — the two live cells are declared and reachable, not silently omitted
whole driver-sql suite, TZ=America/New_York 148 files passed, 9 skipped; 2258 tests passed, 134 skipped
tsc --noEmit --listFiles (driver-sql) exit 0, and the new file IS in the compiled set (1 hit) — the package's tsconfig does not exclude tests, so this reading really covers the edit
pnpm lint (repo-wide eslint . --no-inline-config) exit 0, 61s. Run whole; no narrowing claimed

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 TERM with absolute paths and prove the restore.

leg mutation result
1 sqlite cell asserts the LIVE cells' claim (updated_at instanceof Date) §B1 RED — SQLite genuinely does not hand back a Date, so the type assertion discriminates
2 sqlite control inverted (String(v) is NOT the echoed token) §B2 RED

Evidence for each leg: the removed anchor counted 1 before and 0 after, the injected anchor 0 before and 1 after, and git hash-object moved off the HEAD blob (6640b2b1… to 39893824… and 1bb3d853…). Restore proven by observation, not by an exit code: the blob returned to 6640b2b1… and git diff HEAD came back empty on both legs.

⚠️ Recorded because it nearly produced a false green: the first ablation run scored exit 1 on both legs while running nothing at all — it passed a repo-relative path to a vitest whose cwd is the package, and "No test files found, exiting with code 1" is byte-identical to a red at the exit-code level. The script now refuses any leg whose log lacks a Tests N failed line. 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 hazard live-dialect-matrix.testkit.ts's own eastOfUtc records, 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. Plus check: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.mjsexit 3, its own words: "Nothing was measured: this gate exited before parsing a single summary line." It needs a saved turbo run test log.
  • pnpm check:dual-build-cjs-loadsexit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/", naming 78 packages. Needs a full pnpm build.
  • pnpm check:type-check-debt — exit 1, but it is a REFUSAL, not a ratchet failure: its self-test printed 47 semantic case(s) + … hold and the structural half printed check-type-check-coverage: OK, then --re-measure threw "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-sql appears in neither the DEBT nor the TEST_DEBT ledger, so --re-measure has no entry for it; and the STRUCTURAL half — the one that would demand a new TEST_DEBT entry for a package hiding its tests from tsc — ran and passed, consistent with driver-sql's tsconfig including src/**/* and the --listFiles reading above. Stated as an argument, not as a measurement.

No ratchet counted UP. No baseline was updated, and no gate's --update was invoked.

Clause ② self-declaration, from the actual diff

  • Path limb: NO. The diff touches exactly one path, packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts. Nothing under packages/spec/src/**. git diff --stat against the merge base: 1 file changed, 362 insertions, 0 deletions.
  • Content limb: NO. The file is a test. It adds no export, changes no schema, and no production source file is touched — so no accept/reject verdict moves in either direction and the published surface is unchanged. It only records what the driver already does.

Both limbs read NO, so per the 不预挂 rule no needs:contract-review label is attached.

Changeset

None, and skip-changeset is 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

…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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d7ef6bd542782e9cf4a4721645b7c2a85d732b37packageMentionDocs.

@github-actions github-actions Bot added the tests label Aug 31, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 20:55
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Aug 31, 2026
Any commits made after this event will not be merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] No live-Postgres coverage of the record-data OCC seam — the driver shape that broke it is only ever a hand-made fixture

1 participant