Skip to content

feat(driver-sql): the read door presents datetime values and audit stamps as canonical ISO-Z text on every dialect (#13973, B1 narrow) - #16619

Merged
os-zhuang merged 8 commits into
mainfrom
claude/issue-13973-canonical-iso-read-door
Sep 8, 2026
Merged

feat(driver-sql): the read door presents datetime values and audit stamps as canonical ISO-Z text on every dialect (#13973, B1 narrow)#16619
os-zhuang merged 8 commits into
mainfrom
claude/issue-13973-canonical-iso-read-door

Conversation

@os-musk

@os-musk os-musk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13973

Clause-②: yes

What this lands — the B1 (narrow) ruling, verbatim from comment 5507803003

Ruled: B1, narrow form. For the builtin audit columns (created_at, updated_at) and every declared Field.datetime column, driver-sql's read door presents the canonical instant YYYY-MM-DDTHH:MM:SS.sssZ on every dialect, exactly as it already does on SQLite: the two if (this.isSqlite) gates around repairNaiveUtcAuditTimestamp and normalizeSqliteDatetimeOutput in formatOutput become unconditional (the latter already carries an instanceof Date arm), the aggregate() / distinct() presentation path (readPresentationKind) gains the same arm for the two column classes, and the #14078 NaN guard (ruled B) makes the conversion total. ⛔ The pg client type parser is not touched — Date stays the client-level materialisation; the driver canonicalises at its own read boundary.

Maintainer provenance on the card: 「同意」 (2026-09-02, director seat summon #8). Governed surface in the diff (docs/adr/**) ⇒ this PR stays draft and lands by a human's hand; the ordinary ready → auto-merge → queue path does not apply.

The change, by symbol (packages/drivers/driver-sql/src/sql-driver.ts)

where before after
formatOutput, audit loop over AUDIT_TIMESTAMP_COLUMNS inside if (this.isSqlite); repairNaiveUtcAuditTimestamp is string-only, so a Postgres/MySQL Date passed through untouched unconditional; presentAuditTimestampOutput = Date arm (isoFromValidDate) + the unchanged ADR-0074 string repair
formatOutput, datetimeFields loop inside if (this.isSqlite) unconditional; normalizeSqliteDatetimeOutput's existing Date arm now routes through isoFromValidDate
readPresentationKind no arm for the audit columns on ANY dialect answers the new 'audit_timestamp' kind for created_at / updated_at (unless the author declared them temporal, where the temporal kind wins as formatOutput's datetime fold runs last)
presentReadValue, 'datetime' case this.isSqlite ? normalize… : value unconditional normalizeSqliteDatetimeOutput(value)
presentReadValue, new 'audit_timestamp' case presentAuditTimestampOutput(value) — the same presenter formatOutput calls for those two columns, so aggregate() / distinct() and find() share one presenter per column class (patch round, FINDING-3 below)
withPostgresCalendarDayAsText docblock "Field.datetime depends on it" parser still untouched (D-F2); the dependency claim is recorded as checked and falsified

The JSON, numeric and boolean read repairs keep their dialect gates exactly as they were. The pg and mysql2 client parsers are not touched — §C of the new conformance file proves a raw knex read still hands the driver a Date on both live cells while the read door hands out text for the same row.

One mechanism finding against the ruling's own text. The ruling says the two gates "become unconditional (the latter already carries an instanceof Date arm)". The former — repairNaiveUtcAuditTimestamp — is string-only by contract (if (typeof value !== 'string') return value;), so flipping its gate alone leaves Postgres' and MySQL's Date exactly where it was. The audit loop therefore gets a Date arm of the same spelling (presentAuditTimestampOutput); without it the ablation below stays red on the audit column.

The NaN-guard totality, re-derived at the driver's own boundary (PM Zone 2). The five ruled-B arms PR #16427 landed live in CONSUMERS (protocol.ts, sys-metadata-repository.ts ×2, rest-server.ts ×2, database-loader.ts ×2), not in the driver, so they do not make the driver's conversion total by themselves. What does is the driver's own Date arm: isoFromValidDate returns an Invalid Date unchanged (Number.isNaN(getTime())), the same spelling as isoFromValidDate in the ruled-B family, so the fold never throws — total in the sense #14078 ruled. The consequence is stated rather than hidden: for the one shape #14078 measured (a MySQL zero DATETIME; a Postgres year in 275760..294276) the read door still hands out the client's Invalid Date, and the ruled-B consumer arms absorb it. ADR-0053 D-F3 declares exactly that; sql-driver-14078-invalid-date-materialisation.test.ts §B2/§B3 keep pinning it on both live dialects. The alternatives (fold to null; fold to the text Invalid Date) are a decision the ruling did not make and are listed in the report's open_questions.

The driver comment's dependency claim — falsified on the tree. "Field.datetime depends on [the Date materialisation]": before this change formatOutput never touched a datetime column on Postgres/MySQL (the fold was SQLite-gated), so nothing on the read path consumed the Date; every in-repo reader of the value (the class (a) sites, the new Date(v) wrappers in this package's own live tests, the ruled-B arms) already accepts the text form; and the full driver-sql suite in CI's live shape is green with the fold unconditional (below). The comment is rewritten to say what was checked.

Interaction with PR #16579 (same file, earlier today): none. It touched formatDuplicateGroups (lines ~4213 and its three call sites in the hash-shadow duplicate render); this PR touches the read-presentation region 12 000 lines away. The merge of origin/main (7 commits since the branch point) was clean and none of them touched sql-driver.ts.

Patch round — the contract review's three findings (comment 5572260375, PASS WITH FINDINGS)

The B1-narrow ruling was found landed correctly and is not re-shaped here; the three findings below are what a maintainer would otherwise read wrong or inherit. Commit 536a172913, then origin/main merged (f9591b98b6).

FINDING-1 — the headline sentences stated an absolute invariant the same addendum carves two exceptions out of. Four sentences said, in effect, "a read door never hands out a JS Date for these columns" while D-F3 (an Invalid Date leaves as a Date) and D-F1's own "Not covered: findWithWindowFunctions" (#16609) say otherwise. Each headline now carries its own carve-outs, in the words the anchor invariant already used: the ADR-0053 status line ("every @objectstack/driver-sql record read door but findWithWindowFunctions (#16609) … those doors never hand out a JS Date for those columns, save an Invalid Date, which has no canonical text and passes through unchanged"); D-F1's opening ("every record read door … listed here") and its invariant sentence ("None of these doors hands out a JS Date for these columns, save the one shape D-F3 names … findWithWindowFunctions is not one of these doors"); and the conformance file's docblock (same two carve-outs, with where each is pinned). The contract is not weakened and the exceptions are not deleted; node scripts/check-adr-anchors.mjs and node scripts/check-adr-links.mjs both exit 0 after the edits.

FINDING-2 — "declared = enforced" was short by three doors. Option taken: add the cells (not narrow the sentence). §A5 upsert() (merged onto an existing row, and inserted), §A6 bulkUpdate(), §A7 bulkCreate(), on a second fixture table so their writes cannot move what §B1/§B3 compare against. §0's guard (defined, non-null) is carried inside expectCanonicalInstant, so a door returning a row without its audit columns fails rather than passing over nothing. upsert() and bulkUpdate() read the row back after their statement on every dialect, so the guard applies unqualified. bulkCreate() mirrors §A3's honesty: it asserts over the rows a dialect's bulk insert returns (all rows or none — a door that dropped part of a batch cannot pass as "no RETURNING"), and its always-non-vacuous leg reads the batch back through find() and holds the return equal to the row value for value where a row came back. Which columns a dialect returns is not pinned. D-F1's enforcement paragraph now says exactly this. Measured: 3 doors × 3 cells = 9 new cells, green on SQLite, live Postgres and live MySQL.

FINDING-3 — a reproducible find() vs aggregate()/distinct() divergence this PR introduced. Option taken: (1) one shared presenter, not (2) a gate on the declaration. Mechanism, on the tree: readPresentationKind's added audit arm answered 'datetime', so the two doors folded a number to ISO text where find()'s presentAuditTimestampOutput passes it through (ADR-0074 §3); option (2) would have left the second reachable shape — an epoch INTEGER raw-written into the undeclared audit column — divergent, because the undeclared column would still take the datetime fold at these doors and the audit presenter on find(). So: a new ReadPresentationKind member 'audit_timestamp', answered for created_at / updated_at after the temporal check (a declared temporal audit column takes the temporal kind, exactly as formatOutput's datetime fold runs after its audit fold), and presentReadValue routes it to presentAuditTimestampOutput — the function formatOutput itself calls. presentReadValue's docblock no longer claims "exactly the way formatOutput presents it"; it says what is true: one presenter per kind, the presenter formatOutput calls, and the one place a row walk's composition is not replicated (the SQLite numeric repair before the audit presenter on a declared-number audit column) — where the two differ on any TEXT that Number() accepts but SQLite's NUMERIC affinity leaves as TEXT (hex, binary and octal literals, and Infinity), reachable through create() / update() on the driver's own DDL with an author-declared non-temporal audit column; the delta review reproduced it ('0x10'find() 16, distinct()/max() '0x10') after the first cut of that sentence had claimed the residue needed a hand-made TEXT-affinity column — corrected in 974731b1c6, behaviour untouched (the shape lies outside the B1 ruling's column classes; whether to close it is in the report's open_questions). Pinned as §D on the SQLite cell only: D1 the review's shape (created_at: number, 1700000000000), D2 a raw-written epoch INTEGER in both undeclared audit columns, D3 the control (a legacy zone-naive audit string still folds to ISO at these doors, so the number agreement was not bought by losing ADR-0074's repair). SQLite is the whole coverage and not a shortfall: the DDL never types the audit column from the declaration (builtinColumns skipped, createAuditTimestampColumn runs), so on Postgres it is a timestamptz and on MySQL a DATETIME(3) — neither can hold a number, and the write that would put one there is refused — SQLite's type affinity is what makes the shape reachable at all. Changeset unchanged: its sentence "now present exactly what find() presents" was the claim; the fix is what makes it true.

Ablation for FINDING-3 is under Evidence. Not touched, each for the review's stated reason: the seven out-of-package normalizeSqliteDatetimeOutput comments (a follow-up card, not a rider), #13993#13999 and the five ruled-B arms, findWithWindowFunctions (#16609), the Invalid-Date fold options (a maintainer decision, left in open_questions). One consequence for a file the review called CORRECT, noted rather than acted on: the anchor JSON's invariant names "presentReadValue's datetime arm" — still true (that arm exists and runs on every dialect), now incomplete by one arm name; the optional one-line precision is in the report's open_questions.

Step one — the consumer census (re-runnable)

Consumers that call a Date method directly on a record field fail loudly under B1. Three expressions, each with a positive control, run from the repo root at b4abb0a91 and re-checked on the merged head:

E1 — named timestamp fields followed by a Date method (positive control: the bare \.toISOString\( count over the same globs is 733):

M='(getTime|toISOString|toJSON|valueOf|getFullYear|getMonth|getDate|getDay|getHours|getMinutes|getSeconds|getMilliseconds|getTimezoneOffset|getUTC[A-Za-z]+|setUTC[A-Za-z]+|setFullYear|setMonth|setDate|setHours|setMinutes|setSeconds|setMilliseconds|setTime|toLocaleDateString|toLocaleTimeString|toLocaleString|toDateString|toTimeString|toUTCString)'
FIELDS='(created_at|updated_at|createdAt|updatedAt|deleted_at|deletedAt|occurred_at|occurredAt|recorded_at|recordedAt|expires_at|expiresAt|started_at|startedAt|completed_at|completedAt|reverted_at|revertedAt|last_seen_at|lastSeenAt|authored_at|authoredAt|published_at|publishedAt|_at)'
rg -n --no-heading -g 'packages/**/*.ts' -g '!**/dist/**' -g '!**/node_modules/**' -g '!**/*.d.ts' \
   "(\.|\[['\"])${FIELDS}(['\"]\])?\??\.${M}\(" packages

7 hits, none a driver-row consumer that breaks: platform-objects/…/system-overview-tile-semantics.test.ts:220 (in-memory fixture objects), client/src/return-type-precision.test.ts:770 (the SDK's own DTO type, wire-fed), metadata-protocol/src/protocol-14078-…test.ts:9 (a comment), plugin-auth/src/scim-connection-service.ts:159 (input.expiresAt, a caller-supplied Date, not a read), metadata-protocol/src/protocol.ts:8654-8655 (guarded by instanceof Date — the ruled-B arm, now a no-op on driver rows), plugin-auth/src/session-tombstone.test.ts:427 (a Date the code WRITES, read off a mock's patch).

E2 — any receiver, any field, then a Date method, non-test source only (same M; new Date(, Date.now, Date.UTC, process. and this.clock/now receivers excluded): 6 hits — the scim-connection-service.ts:159 and protocol.ts:8654-8655 sites above, create-objectstack/src/created-summary.ts:221-222 (Number.prototype.toLocaleString), metadata/src/loaders/filesystem-loader.ts:336 (fs.Stats.mtime). None reads a driver row.

E3 — as Date / angle-bracket Date casts in non-test source: 0 hits; positive control: the same expression over *.test.ts in driver-sql hits the #13567 / #14078 pins (4 and 3 as Date sites on the pre-change tree).

E4 — the empirical census inside the only package that sees a live dialect: the whole driver-sql suite in CI's live shape (TZ=America/New_York, both live cells, OS_EXPECT_LIVE_DIALECT_MATRIX=1) — every pin that asserted the old Date side is listed under "repaired" below; nothing else went red.

Repaired in this PR: the three driver-sql pins that asserted the Date side (#13567 §B1–§B4, #14078 §B1, and the #11389 comment restating the falsified dependency). Filed: none needed — no non-test consumer calls a Date method on a driver-row field. The seven class (c) site cards (#13993#13999) and the five ruled-B arms are untouched, per the ruling: under B1 their canonicalisations become no-ops, not conflicts.

Contract line

  • ADR-0053 addendum D-F1..D-F3 (docs/adr/0053-date-and-datetime-semantics.md, status line + a new addendum): D-F1 the read door returns datetime values and the audit stamps as the canonical ISO-Z text on every dialect (declared = enforced, cells named); D-F2 folded at the driver's read boundary, client parsers untouched; D-F3 the Invalid Date residue passes through. ADR-0074 gets a one-line status pointer (its read repair is no longer SQLite-only in effect). The scripts/adr-anchors/…sql-driver.ts.json anchor gains ADR-0053 with the invariant in its failure text.
  • Conformance cellssql-driver-13973-canonical-iso-read-door.test.ts (new): per cell of the D-A3 driver axis, find/findOne/update-return/create-return (§A1–§A3), upsert/bulkUpdate/bulkCreate returns (§A5–§A7, patch round), aggregate (min/max over the declared datetime AND the audit columns, a raw temporal group key), distinct over all three, the Field.date control untouched, §C the raw-knex read proving the fold is the driver's, and §D (SQLite) the one-presenter agreement between find(), distinct() and aggregate() on the audit columns. sql-driver-13567-… §B re-pinned to the ruled shape on every cell (its §A, the record of why a Date at the door was a defect, is kept). sql-driver-14078-… §B1 control re-pinned to text; §B2/§B3 (the Invalid Date residue) unchanged.
  • Changeset minor for @objectstack/driver-sql, naming the consumer-visible change (Postgres/MySQL in-process consumers receive text where they received a Date; the wire is unchanged).

Evidence

Everything below ran in the worktree on the branch; every exit code was captured after a redirect, never through a pipe, and every gate verdict quoted is the gate's own line. First round's union at c8fe535616; patch round's union at f9591b98b6 (the fix 536a172913 + the merge of 20 origin/main commits, none touching this PR's driver, test or ADR files); docblock correction at git rev-parse --short HEAD = 974731b1c6 (comment-only, 974731b1c6 on top of a clean merge of 3 more origin/main commits 48f1ad8a97; at that head the four touched files on all three live cells Test Files 4 passed (4), Tests 111 passed (111), driver-sql typecheck 0 errors, check-adr-anchors / check-adr-links exit 0, gate union re-derived and re-run: "Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN."; no ablation, none is owed for a comment).

Patch round, on a fresh local Postgres 16.13 (timezone=Asia/Shanghai, :54331) and MySQL 8.0.46 (default-time-zone=+08:00, :33073), process TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1 — dependency closure rebuilt after the merge (pnpm --filter '@objectstack/driver-sql^...' build, lock VERDICT command-exit 0):

  • Baseline before any patch-round edit, the four touched files on all three cells: Test Files 4 passed (4), Tests 99 passed (99).
  • After the patch, same four files, all three cells, at f9591b98b6: Test Files 4 passed (4), Tests 111 passed (111) — +12 = the 9 door cells (3 doors × 3 cells) + §D1–§D3, lock VERDICT command-exit 0.
  • Ablation for FINDING-3, committed fix first, both legs traced. Mutation = the one line the review named as the cause, readPresentationKind's audit arm answering 'datetime' again (anchor counts before: fixed 1 / mutant 0; after: fixed 0 / mutant 1; on-disk blob caec12253e… ≠ HEAD blob 027b83b077…). Predicted: red on §D1/§D2's distinct()/aggregate() legs and §D3's toContain(EPOCH) leg, green on every §A/§B/§C cell of every dialect. Observed, the conformance file on the full matrix: Tests 3 failed | 36 passed (39), exactly §D1, §D2, §D3, e.g. distinct(created_at) disagrees with find(): expected [ '2023-11-14T22:13:20.000Z', … ] to deeply equal [ 1700000000000, 1700000000001 ] — the reviewer's reproduction, verbatim direction. Restore by git checkout HEAD -- ABS_PATH: on-disk blob 027b83b077… = HEAD:…sql-driver.ts, git diff HEAD empty, git status --porcelain empty for the path, anchor counts back to fixed 1 / mutant 0; re-run Tests 39 passed (39). Stale build ruled out two ways, both printed by the script: this worktree never built driver-sql itself (no packages/drivers/driver-sql/dist exists), and the tests import ./index.js, which vitest resolves to src/index.ts (the pairing scripts/check-test-source-alias.mjs pins) — the mutated bytes are what ran.
  • Typecheck at f9591b98b6: pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles — 0 errors, the conformance file in the program (1 --listFiles hit).
  • Lint, narrowed with its three pieces of evidence at f9591b98b6: --print-config resolves for both touched source files (exit 0), --format json counts 2 files / 0 errors / 0 warnings, and eslint.config.mjs states no type-aware linting for any file (no parserOptions.project, no typed rule), so this diff cannot move an untouched file's verdict.
  • Gates at f9591b98b6, derived from the merge-base changeset with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no hand-listed paths; 72 families), each exit code captured after a redirect, reconciled with --ran: "Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN." 69 of 72 exit 0 (check:adr-anchors, check:adr-links, check:adr-symbol-anchors, check:query-options-erasure, check:test-source-alias, check:type-check-coverage, check:dts-closure, check:nul-bytes among them). NOT MEASURED, exit 3 each, in the gates' own words: check:type-check-debt ("check-type-check-coverage: PREREQUISITE NOT MET"), check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/"), check:doc-formula-expressions ("PREREQUISITE NOT MET — the workspace package @objectstack/lint is not built … Nothing was measured"). The cause is the unbuilt dependency closure, not memory (the seat measured check:type-check-debt at 8192 MB with the same exit 3); none was chased with a workspace build.

First round's evidence, at c8fe535616, kept as recorded:

Live cells, locally, on a real Postgres 16 and MySQL 8.0. The container carries the server binaries, so both were provisioned the way CI's job does (PG timezone=Asia/Shanghai on :54329, MySQL default-time-zone=+08:00 on :33071, process TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1 so a missing cell is red, not skipped); the testkit's three-way zone skew guard passed on both cells. Data dirs under /tmp/os13973-live, torn down after the run.

  • Baseline before any edit (the OLD pins, updated_at instanceof Date on both live cells): Test Files 2 passed (2), Tests 29 passed (29).
  • Full driver-sql suite in CI's shape at the fold unconditional: Test Files 167 passed (167), Tests 3623 passed | 1 skipped (3624), lock VERDICT command-exit 0 (the one skip is pre-existing and not in a touched file).
  • The three conformance files at the final HEAD: Test Files 3 passed (3), Tests 60 passed (60).

Firing control (reverse verification), committed fix first, both legs traced. sql-driver.ts restored to its BASE blob (91337b2b…, hash-verified on disk; presentAuditTimestampOutput count 0, the old this.isSqlite ? … : value arm count 1), the three conformance files run on the live matrix: Tests 24 failed, every failure on the live postgres / live mysql cells — the new file's §A1–§A3, §B1–§B3 and §C, #13567's §B1–§B4, #14078's §B1 — and every SQLite cell green. Predicted direction was red on §A/§B and green on §C; §C reddened too, because its equality leg compares the raw Date's ISO text against the presented value, which under the old gate is itself a Date — reported as observed. Restore by git checkout HEAD -- ABS_PATH: on-disk blob 08d23fe4… = HEAD:…sql-driver.ts, git diff HEAD empty, git status --porcelain empty for the path; re-run Test Files 3 passed (3), Tests 60 passed (60). No build sits between the mutation and the run: driver-sql's tests import ./index.js, resolved by vitest to src/index.ts (the pairing scripts/check-test-source-alias.mjs pins), so the mutated bytes are what ran — stated, not skipped.

Typecheck. pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles: 0 errors at the final HEAD, and the new test file is present in the program (1 --listFiles hit; driver-sql's tsconfig has no test exclusion) — so the typecheck genuinely covers it. A first pass caught two Array.prototype.at uses (TS2550, lib target), fixed.

Lint. Full repo scan pnpm lint (eslint . --no-inline-config): exit 0. Also the narrowed form with its three pieces of evidence: population from eslint's own config (--print-config resolves for all 5 touched files, exit 0), --format json counts 5 files / 0 errors / 0 warnings, and type-aware linting is not enabled (eslint.config.mjs sets no parserOptions.project and no typed rule), so this diff cannot move any untouched file's verdict.

Gates. Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after git fetch origin main and the merge (72 families; identical list before and after the merge). Each run with its exit code written to a TSV; union re-run at the final HEAD c8fe535616; reconciled with --ran: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".

  • 68 of 72: exit 0. Includes check:adr-anchors (with the new ADR-0053 invariant), check:adr-links, check:adr-symbol-anchors, check:query-options-erasure (a first pass grew the test surface 236 → 238 through two as any aggregate queries in the new file; they are on-contract and now carry the declared DriverQuery type — ratchet back to green, not widened), check:type-check-coverage, check:dts-closure (green after removing a half-built packages/lint/dist my own single-package build attempt had left; that artefact is not in the diff).
  • NOT MEASURED, in the gates' own words, exit 3 each: check:type-check-debt ("PREREQUISITE NOT MET … measuring now would … silently measure a DIFFERENT WORLD"), check:dual-build-cjs-loads ("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/"), check:doc-formula-expressions ("PREREQUISITE NOT MET — @objectstack/lint is installed, but something it imports is not": @objectstack/sdui-parser unbuilt). All three need the whole-workspace build CI performs; per the PM's correction none was chased with a workspace build here.

Clause-②: yes — a published driver's read-door output shape changes on two dialects. needs:contract-review hung on the card and the PR together with this PR's opening.

维护者速读(草稿)

改了什么:@objectstack/driver-sql 的读门(find / findOne / createupdateupsertbulkCreatebulkUpdate 的返回行,以及 aggregatemin/maxdistinct)在 Postgres 和 MySQL 上,对内建的 created_at / updated_at 和所有声明为 datetime 的字段,改为交出与 SQLite 一致的规范 ISO 文本(2026-09-07T12:00:00.123Z),不再交出 JS Date 对象。数据库客户端解析器一处未动;驱动在自己的读边界折叠。aggregate / distinct 对两个审计列走的是 find() 同一个呈现函数,三个门对同一列的答案逐值一致。ADR-0053 追加 D-F1..D-F3(状态行与 D-F1 的不变量句自带两个例外:无法折叠的 Invalid Date 原样交出;findWithWindowFunctions 不在覆盖面内,已立 #16609),ADR-0074 加一行指针,changeset minor

为什么改:同一个「最后更新时间」在 SQLite 上是文本、在生产默认的 Postgres 上是 Date,44 个读库的包里 43 个只在文本侧测过,这个类已经出过 8 次事故(线上 1 次,普查读代码 7 次),方向全部是「期望文本、拿到 Date」,反向零次。这是您 2026-09-02 对 #13973 拍的 B1 收窄版(「同意」),本 PR 是落地。

风险与代价(含回滚):Postgres / MySQL 上的进程内消费者从此拿到字符串而不是 Date;直接对记录字段调 .getTime() 一类方法的代码会响亮报错——本 PR 的普查在非测试源码里一处都没找到;经 HTTP/JSON 的调用方看不到任何变化(JSON.stringify 本来就把 Date 序列化成同一段文本)。无法折叠的坏值(MySQL 零值日期、Postgres 超出 JS 范围的年份)原样交出 Invalid Date,与 #14078 的裁决一致。回滚:revert 本 PR 即可,无数据迁移、无存储变更;本地实测 Postgres 16 + MySQL 8.0 全绿(167 个文件 3623 个用例)。

席位意见:(留空)

你要做的:确认 ADR-0053 的 D-F1..D-F3 与您的裁决一致(状态行与 D-F1 现在是完整的一句话,不必翻到 D-F3 才知道它有例外),然后人工合并(受管面,不走队列)。若您希望坏值改交 null 或文本 Invalid Date,报告的 open_questions 列了两个选项,可另行裁定。

验收备注

…al ISO-Z text on every dialect

The two `if (this.isSqlite)` gates in `formatOutput` around the audit-column
repair and the `Field.datetime` fold become unconditional, the audit-column arm
gains the `Date` fold the string-only repair never had, `presentReadValue`'s
`datetime` arm runs on every dialect, and `readPresentationKind` answers
`datetime` for the two builtin audit columns so `aggregate()`/`distinct()`
present them too. The pg/mysql2 client parsers are untouched; an Invalid `Date`
passes through unchanged. ADR-0053 addendum D-F1..D-F3, ADR-0074 status
pointer, anchor, changeset (minor), and the conformance cells: a new per-cell
file plus the #13567 and #14078 pins re-pointed at the ruled shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
driver-sql's tsconfig lib predates `at()`; the conformance cell reads the
last sorted element by index instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
…iverQuery

The query-options erasure ratchet counts an `as any` at a `find|findOne|
count|aggregate` argument; the two aggregate queries are on-contract, so they
carry the declared `DriverQuery` type instead of an erasure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
@github-actions github-actions Bot added the size/l label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 10 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4fe00b80e7d2da485cae7a777b29609770d8c80b.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 33 pages)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 10 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 4fe00b80e7d2da485cae7a777b29609770d8c80bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d63fe8d728e5e8524160846c1b184b201bdcbce9 — the merge of head 974731b1c66e96a104766ff078f2a7b38b723411 into base 4fe00b80e7d2da485cae7a777b29609770d8c80b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d63fe8d728e5e8524160846c1b184b201bdcbce9 && git checkout d63fe8d728e5e8524160846c1b184b201bdcbce9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4fe00b80e7d2da485cae7a777b29609770d8c80b 974731b1c66e96a104766ff078f2a7b38b723411 && git checkout -B drift-repro 4fe00b80e7d2da485cae7a777b29609770d8c80b && git merge --no-ff 974731b1c66e96a104766ff078f2a7b38b723411

node scripts/docs-audit/affected-docs.mjs --json 4fe00b80e7d2da485cae7a777b29609770d8c80b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4fe00b80e7d2da485cae7a777b29609770d8c80b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ough find()'s own presenter; pin the three remaining row doors; qualify the D-F1 headline

Contract review of PR #16619 (PASS WITH FINDINGS) named three things to fix
before the governed diff reaches a maintainer.

FINDING-1: ADR-0053 D-F1, its status line and the conformance file's docblock
stated the read-door invariant absolutely while the same addendum carves two
exceptions out of it (D-F3's Invalid `Date`; `findWithWindowFunctions`,
#16609). Each headline sentence now carries its own carve-outs, agreeing with
the anchor invariant that already did.

FINDING-2: D-F1 said "asserts it per cell" over seven row doors; the file
asserted four. `upsert()`, `bulkUpdate()` and `bulkCreate()` returns are now
cells (§A5–§A7) on a second fixture table, with §0's guard carried inside
`expectCanonicalInstant`; `bulkCreate()` asserts over the rows a dialect's
bulk insert returns and reads the batch back through `find()` on every
dialect, so the cell measures something where the dialect has no RETURNING.

FINDING-3: `readPresentationKind` routed `created_at` / `updated_at` to the
`datetime` kind, so `aggregate()` / `distinct()` folded a number to ISO text
where `find()` (ADR-0074 §3) passes it through — an author-declared
`created_at: number` read `1700000000000` off `find()` and
`"2023-11-14T22:13:20.000Z"` off `distinct()`. A new `audit_timestamp` kind
routes those columns to `presentAuditTimestampOutput`, the presenter
`formatOutput` itself calls, so both doors share one presenter per column
class; `presentReadValue`'s docblock now says exactly where a row walk's
composition is and is not replicated. §D pins the agreement on SQLite, the
only dialect whose audit column can hold a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…ergence's real reach

The sentence replacing the retired "exactly the way formatOutput presents it"
claim said the two presenters differ only on a numeric-looking TEXT in a
hand-made TEXT-affinity audit column. The delta contract review of PR #16619
reproduced the divergence through the driver's own write door on the driver's
own DDL (author-declared non-temporal created_at, `'0x10'` → find() 16,
distinct()/max() '0x10'; same for '0b101', '0o17', '  Infinity'). The
docblock now says what is true: any TEXT that Number() accepts but SQLite's
NUMERIC affinity leaves as TEXT, reachable through create()/update(). No
behaviour changes; the shape is outside the B1 ruling's column classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@os-zhuang
os-zhuang marked this pull request as ready for review September 8, 2026 00:29
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 00:29
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 45cfa1b Sep 8, 2026
41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13973-canonical-iso-read-door branch September 8, 2026 01:09
os-zhuang pushed a commit that referenced this pull request Sep 8, 2026
…FROM/TO table

Contract-review patch round on PR #16716 (findings F2, F3, F5 and the
non-governed half of F4). No production code changes.

F5 — merged origin/main, so this branch now carries #16619: `formatOutput`'s
instant gates are unconditional, which is the presenter this door actually
ships through. Every CI leg on the previous head measured the pre-B1 presenter.

F3 — `sql-driver-window-function-output.test.ts` gains a `measure(cell)` arm
over `DIALECT_CELLS`, declared through `declareDialectCell` so an
unprovisioned cell is a NAMED SKIP and never a silent pass. It asserts the two
halves the SQLite-only arm cannot: `typeof row.ok === 'boolean'` (the MySQL
half of the `isSqlite || isMysql` boolean gate) and the canonical
`YYYY-MM-DDTHH:MM:SS.sssZ` text for `closed_at` / `created_at` / `updated_at`
(the PG + MySQL instant fold). SS4 reads the same row back through raw knex to
prove the fold is the driver's and not the client's.

F2 — the changeset gains a per-class, per-dialect FROM/TO table covering all
seven classes this door moves: adds `external.columnMap` (remote column key ->
local field key, every dialect), the SQLite numeric-string -> `number` move,
the MySQL `Field.date` `Date` -> `YYYY-MM-DD` move and `Field.time` ->
canonical `HH:MM:SS[.fff]`, and spells the instant TO as the canonical text on
every dialect. `minor`, the BREAKING banner and the ADR-0087 disposition are
unchanged.

F4 (non-governed half) — the header comment of
`sql-driver-13973-canonical-iso-read-door.test.ts` said this door applies no
read presentation. It routes through `formatOutput` since #16609, so the
comment now says that and flags that ADR-0053 D-F1 still records it as not
covered, with governed docs-only card #16782 carrying the amendment.

`docs/adr/**` is untouched here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
os-musk pushed a commit that referenced this pull request Sep 8, 2026
…t's timestamp passes

Since #13973 / PR #16619 (ADR-0053 D-F1) both of `SqlDriver#formatOutput`'s
timestamp passes -- the AUDIT_TIMESTAMP_COLUMNS pass and the
normalizeSqliteDatetimeOutput pass over datetimeFields -- run on every dialect.
Seven out-of-package comments still described them as gated on
`if (this.isSqlite)`.

The rest-server.ts one was not merely stale: it warned future authors that "a
declared Field.datetime is therefore NOT protected on Postgres/MySQL", which
invites the tolerant consumer-side coercion ADR-0053 / #16619 expressly forbid.
Corrected first, per the triage ruling on the card.

Both carve-outs are preserved rather than flattened:
  - withPostgresCalendarDayAsText is untouched (D-F2) -- the client still hands
    back a Date; only "nothing folds it" was false.
  - the Invalid `Date` residue still stands (D-F3) -- no sentence claims the
    read door never hands out a Date.

Comments only; no runtime behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones

3 participants