Skip to content

Commit 45cfa1b

Browse files
os-muskclaude
andauthored
feat(driver-sql): the read door presents datetime values and audit stamps as canonical ISO-Z text on every dialect (#13973, B1 narrow) (#16619)
* feat(driver-sql): present datetime values and audit stamps as canonical 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 * test(driver-sql): spell the last-element read without Array.prototype.at 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 * test(driver-sql): type the conformance cell's aggregate queries as DriverQuery 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 * fix(driver-sql): aggregate()/distinct() present the audit columns through 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 * docs(driver-sql): presentReadValue's docblock states the residual divergence'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 --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 6ba0db4 commit 45cfa1b

9 files changed

Lines changed: 1088 additions & 177 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973).
6+
7+
**Consumer-visible change, Postgres and MySQL only.** An in-process consumer reading such a column off a `find()` / `findOne()` row, off the row `create()` / `update()` / `upsert()` / `bulkCreate()` / `bulkUpdate()` return, or out of `aggregate()` (`min` / `max`, a raw temporal group key) or `distinct()`, receives a `string` where it received a JS `Date`. The wire is unchanged: `JSON.stringify` already serialised that `Date` as the same ISO text, so REST, MCP and SDK callers see nothing move. A consumer that called a `Date` method directly on the field (`.getTime()`, `.toISOString()`, `.getFullYear()`) now fails loudly with a `TypeError` instead of silently working on one dialect; the sweep behind this change found none in the repository's non-test sources. A consumer that compared, sorted, keyed or formatted the value as text — the shape eight production-driver defects had (#13382, #13993#13999) — is now correct by construction on every dialect.
8+
9+
- **Where the fold happens.** At the driver's own read boundary (`formatOutput` for rows, `presentReadValue` for the aggregate/distinct doors). The `pg` and `mysql2` client parsers are untouched: a `Date` is still what the client materialises, and a raw knex read still hands it back. Only the driver's read doors changed.
10+
- **The builtin audit columns gain an `aggregate()` / `distinct()` arm on every dialect.** `max(updated_at)` and `distinct('created_at')` had no read presentation at all before — on SQLite they even missed ADR-0074's legacy-row repair — and now present exactly what `find()` presents.
11+
- **An Invalid `Date` is the one shape the fold hands through unchanged** (#14078: a MySQL zero `DATETIME`; a Postgres year past 275760). It has no canonical text; the fold never throws on it, and the consumer-side guards #14078 landed absorb it as before.
12+
13+
The per-site canonicalisations landed for #13993#13999 and #14078 stay correct and become no-ops on driver rows; nothing is removed here.

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0053: `date` is a timezone-naive calendar day; `datetime` is an instant rendered in a reference timezone
22

3-
**Status**: Accepted (2026-06-16) — Phase 1 + addendum D-A1 implemented (`sql-driver.ts` `toDateOnly` write/read/filter normalization; analytics `coerceTemporalFilterValue`), Phase 2 landing incrementally; D-A2 resolved 2026-07-30: `temporalFilterValue` + `temporalFilterColumnSql` are optional `IDataDriver` contract members with identity semantics, and analytics types its driver seam from the contract. **Partly superseded (2026-07-29, addendum D-B1..D-B4):** Phase 1's "`Field.datetime` stays stored as UTC epoch ms" is replaced by one canonical UTC instant per dialect — `YYYY-MM-DDTHH:MM:SS.sssZ` text on SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL — applied on write and to filter comparands alike (#3912, #3942). **Extended (2026-07-30, addendum D-C1..D-C3):** `Field.time` takes the same construction — canonical `HH:MM:SS[.fff]` wall-clock text, one function on write/filter/read, `TIME(3)` on MySQL, UTC `NOW()` defaults on every dialect (#3994).
3+
**Status**: Accepted (2026-06-16) — Phase 1 + addendum D-A1 implemented (`sql-driver.ts` `toDateOnly` write/read/filter normalization; analytics `coerceTemporalFilterValue`), Phase 2 landing incrementally; D-A2 resolved 2026-07-30: `temporalFilterValue` + `temporalFilterColumnSql` are optional `IDataDriver` contract members with identity semantics, and analytics types its driver seam from the contract. **Partly superseded (2026-07-29, addendum D-B1..D-B4):** Phase 1's "`Field.datetime` stays stored as UTC epoch ms" is replaced by one canonical UTC instant per dialect — `YYYY-MM-DDTHH:MM:SS.sssZ` text on SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL — applied on write and to filter comparands alike (#3912, #3942). **Extended (2026-07-30, addendum D-C1..D-C3):** `Field.time` takes the same construction — canonical `HH:MM:SS[.fff]` wall-clock text, one function on write/filter/read, `TIME(3)` on MySQL, UTC `NOW()` defaults on every dialect (#3994). **Extended (2026-09-07, addendum D-F1..D-F3):** the READ side takes the same canon — every `@objectstack/driver-sql` record read door but `findWithWindowFunctions` (#16609) presents `Field.datetime` values and the builtin `created_at` / `updated_at` audit stamps as the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text on every dialect, folded at the driver's read boundary with the client parsers untouched; those doors never hand out a JS `Date` for those columns, save an Invalid `Date`, which has no canonical text and passes through unchanged (#13973, maintainer ruling B1 narrow, 2026-09-02).
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0032](./0032-unified-expression-layer.md) (unified expression layer — CEL dialect, `today()`/`daysFromNow()`), [ADR-0014](./0014-record-form-field-type.md) (field types)
66
**Consumers**: `@objectstack/spec` (`Field.date`/`Field.datetime`), `@objectstack/driver-sql` (`coerceFilterValue`, `formatInput`/`formatOutput`, `dateFields`/`datetimeFields`), `@objectstack/formula` (`stdlib` time functions, `cel-engine` hydration), `@objectstack/objectql` (`applyFormulaPlan`), schedule/cron executors, report/analytics date bucketing, `sys-user-preference.timezone`.
@@ -1020,3 +1020,153 @@ again — the #5499 freeze that used to be the other half of this condition was
10201020
dissolved on 2026-08-11. The conversion itself is pinned by
10211021
`mongodb-time-storage.test.ts`, which is unaffected: it is pure, needs no
10221022
server, and still runs everywhere.
1023+
1024+
---
1025+
1026+
## Addendum (2026-09-07) — the read door presents ONE instant shape on every dialect (D-F1..D-F3, #13973)
1027+
1028+
> **Status:** landed. Extends D-B1's canon from the storage and comparand sides
1029+
> to the READ side, and ADR-0074's audit-column read repair from SQLite to every
1030+
> dialect. Provenance: maintainer ruling on #13973 — 「同意」 to the director
1031+
> seat's analysis recommending B1 (narrow), 2026-09-02, recorded on the card as
1032+
> comment 5507803003. The options that ruling declined are listed at the end.
1033+
1034+
### What the read side actually was
1035+
1036+
D-B1 gave `Field.datetime` one storage form per dialect and one comparand rule,
1037+
and said nothing about what the driver HANDS BACK. That was decided by the
1038+
client library. `formatOutput` folded every SQLite storage shape to
1039+
`YYYY-MM-DDTHH:MM:SS.sssZ` — inside `if (this.isSqlite)`. On Postgres and MySQL
1040+
a `timestamptz` / `DATETIME(3)` left the read door as node-pg's / mysql2's JS
1041+
`Date`; on SQLite (and the memory driver) as canonical text. The builtin audit
1042+
columns had the same gate around ADR-0074's repair, and the `aggregate()` /
1043+
`distinct()` presentation (`readPresentationKind`) had no arm for them on ANY
1044+
dialect. One column, two runtime types, through the same `any`-shaped record —
1045+
invisible to the type system, and to every test that never ran on a live
1046+
dialect.
1047+
1048+
The #13973 census measured what that cost. 44 packages call a read door; 43 of
1049+
them had only ever seen the text side (the one CI job with a live Postgres and
1050+
MySQL runs `driver-sql` alone). Eight consumers were wrong on the production
1051+
default driver — #13382 in production (the OCC seam compared `String(v)` on
1052+
both sides and refused every guarded save), #13993#13999 by reading (an
1053+
idempotency window that never expires; a migration that persisted
1054+
`Date.toString()`; a timeline sorted by weekday name; a `z.string()` field
1055+
holding a `Date`) — every one in the direction "expected the text, received a
1056+
`Date`", none the reverse. The driver comment stating the `Date` side as
1057+
deliberate ("`Field.datetime` depends on it") was checked on the tree and did
1058+
not hold: nothing on the read path consumed the `Date`.
1059+
1060+
### D-F1 — Every read door presents the canonical instant text, on every dialect
1061+
1062+
For the builtin audit columns (`created_at`, `updated_at`) and every declared
1063+
`Field.datetime` column, every record read door of `@objectstack/driver-sql`
1064+
listed here presents the value as `YYYY-MM-DDTHH:MM:SS.sssZ` text — on SQLite,
1065+
Postgres and MySQL alike, exactly as SQLite always did:
1066+
1067+
- `find()`, `findOne()`, and the rows `create()`, `update()`, `upsert()`,
1068+
`bulkCreate()` and `bulkUpdate()` return (all through `formatOutput`, whose two
1069+
gates are now unconditional and whose audit-column arm folds a `Date`);
1070+
- `aggregate()` for `min` / `max` over such a column and for a raw temporal
1071+
group key, and `distinct()` over such a column (`presentReadValue`, whose
1072+
`datetime` arm is now unconditional, and `readPresentationKind`, which now
1073+
routes the two audit columns to the same `presentAuditTimestampOutput`
1074+
`formatOutput` applies — one presenter per column class, shared by every
1075+
door, so a value `find()` passes through as a number — ADR-0074 §3's epoch
1076+
INTEGER, or an author-declared non-temporal `created_at` — is that same
1077+
number here, never ISO text at this door alone).
1078+
1079+
None of these doors hands out a JS `Date` for these columns, save the one
1080+
shape D-F3 names: an Invalid `Date`, which has no canonical text and passes
1081+
through unchanged. `findWithWindowFunctions` is not one of these doors (see
1082+
Consequences; #16609). Declared = enforced:
1083+
`sql-driver-13973-canonical-iso-read-door.test.ts` asserts it per cell of the
1084+
D-A3 driver axis for every door listed — `bulkCreate()` over the rows a
1085+
dialect's bulk insert returns (MySQL, with no `RETURNING`, returns none, and
1086+
that cell reads the batch back through `find()` instead) — the SQLite cell
1087+
everywhere, the Postgres and MySQL cells under `Temporal Conformance (live PG
1088+
+ MySQL)`, with the three-way zone skew guard so a `Z` that only survived
1089+
because every clock agreed cannot pass — and
1090+
`sql-driver-13567-audit-stamp-materialisation.test.ts` re-pins the audit
1091+
column at the OCC seam's door. The same file's §D pins, on SQLite, that
1092+
`aggregate()` / `distinct()` present the audit columns through the presenter
1093+
`find()` uses, on the two shapes where a different one would show (an
1094+
author-declared non-temporal `created_at`; a raw-written epoch INTEGER) —
1095+
SQLite because its type affinity is what lets a number sit in that column at
1096+
all; the `timestamptz` / `DATETIME(3)` the DDL types it as elsewhere cannot.
1097+
1098+
Why text rather than "everything a `Date`" (the maintainer asked exactly this:
1099+
「为什么不能都用日期类型」): every platform with a metadata layer decides the
1100+
in-process type from the declared field type at its own read boundary, and
1101+
ObjectStack had already declared that type — D-B1's text — everywhere but this
1102+
one door. A `Date` is not a value type (`===` compares identity, a `Map` key is
1103+
by reference, it is mutable); `Invalid Date` is a `Date` whose `toISOString()`
1104+
throws; its local-zone methods answer differently on every host; JSON cannot
1105+
carry it; and it holds less precision than `timestamptz`. The canonical text
1106+
makes `String(v)`, `===`, a template, a sort and a `Map` key correct by
1107+
construction, which is what an author — human or AI — reaches for first. A
1108+
`Date` belongs at exactly two places: the client parser (D-F2) and the moment
1109+
arithmetic happens (`new Date(text).getTime()`, which every class (a) site in
1110+
the census already spells and which accepts both shapes).
1111+
1112+
### D-F2 — Folded at the driver's read boundary, not at the client parser
1113+
1114+
The pg and mysql2 type parsers are not touched. A `Date` stays the client-level
1115+
materialisation — a raw knex read of the same row still hands one back, and a
1116+
host's own `pg` clients keep the stock behaviour — and the driver canonicalises
1117+
in `formatOutput` / `presentReadValue`. This is the narrow form of B1: the
1118+
`date` parser installed by `withPostgresCalendarDayAsText` stays the one place
1119+
a clock is chosen, the instant types keep their stock parser, and nothing
1120+
outside the driver's own read doors moves. §C of
1121+
`sql-driver-13973-canonical-iso-read-door.test.ts` measures this on every live
1122+
cell: the raw read is a `Date`, the read door is text, and the two name the
1123+
same instant.
1124+
1125+
### D-F3 — The one shape the fold cannot canonicalise passes through
1126+
1127+
An Invalid `Date` — a `Date` whose time value is `NaN`, which #14078 measured
1128+
both live dialects to produce from rows already on disk (a MySQL zero
1129+
`DATETIME`; any Postgres year in 275760..294276) — has no canonical text. The
1130+
fold is total in the sense #14078 ruled for the shared consumer spelling: it
1131+
never throws, and it hands the client's Invalid `Date` through unchanged. It is
1132+
neither nulled (a stored value silently erased) nor spelled as the text
1133+
`Invalid Date` (a wire change: `JSON.stringify` already serialises the shape as
1134+
`null`); it leaves as the one `Date` the consumer-side guards #14078 landed
1135+
already absorb. `sql-driver-14078-invalid-date-materialisation.test.ts` pins
1136+
both halves: the control instant leaves as text, the Invalid `Date` leaves as
1137+
itself.
1138+
1139+
### Consequences
1140+
1141+
- **Consumer-visible, Postgres and MySQL only** (changeset `minor` for
1142+
`@objectstack/driver-sql`): in-process consumers receive a `string` where
1143+
they received a `Date`. The wire is unchanged — `JSON.stringify` already
1144+
serialised the `Date` as the same ISO text. A consumer that called a `Date`
1145+
method directly on such a field fails loudly with a `TypeError`; the census
1146+
for this addendum (its expressions are recorded on #13973's landing PR)
1147+
found none in non-test sources. A consumer that compared, sorted, keyed or
1148+
formatted the value as text is now correct on every dialect.
1149+
- The per-site canonicalisations landed for #13993#13999 and the five total
1150+
`Date` arms #14078 landed become no-ops on driver rows. They stay correct and
1151+
are not removed here; retiring them is separate, deliberate work.
1152+
- ADR-0074's read repair is no longer SQLite-only in effect: its string arm is
1153+
unchanged and now runs on every dialect (a no-op on canonical text), and a
1154+
`Date` arm sits beside it.
1155+
- D-A3's matrix gains the read-shape cell. `Temporal Conformance (live PG +
1156+
MySQL)` is the job that proves it; its package set is not widened.
1157+
- Not covered: `findWithWindowFunctions`, which applies no read presentation of
1158+
any kind today (booleans, dates and JSON included) — a pre-existing gap of its
1159+
own, recorded rather than folded in.
1160+
1161+
### Options not taken
1162+
1163+
- **B1-full** — text at the pg parser. Same effect, one more decision reversed
1164+
(the parser is scoped per pool and would then differ from a host's own `pg`
1165+
clients), marginally better precision. Not taken: the driver's read boundary
1166+
is the layer that owns the declared type.
1167+
- **B2** — a union return type the consumer must narrow. Moot once the read
1168+
type is `string`; it only made sense if both shapes were kept.
1169+
- **B3** — accept the divergence and add a shared `Date` fixture. It accepts a
1170+
divergence this ADR had already declared away and leaves 43 packages
1171+
untested against the shape. Its fixture folds into the conformance cells
1172+
above.

docs/adr/0074-canonical-audit-timestamp-storage-on-sqlite.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0074: Audit timestamps are stored in one canonical, timezone-explicit format on SQLite
22

3-
**Status**: Accepted (2026-06-26)
3+
**Status**: Accepted (2026-06-26). **Read side extended (2026-09-07):** the read repair this ADR added on SQLite is, since [ADR-0053 addendum D-F1](./0053-date-and-datetime-semantics.md) (#13973), one presentation on every dialect — `created_at` / `updated_at` leave every read door as the canonical ISO-Z text on Postgres and MySQL too, where they used to leave as the client library's `Date`. The storage decision below is unchanged.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0053](./0053-date-and-datetime-semantics.md) (`datetime` is an instant stored as UTC)
66
**Consumers**: `@objectstack/driver-sql` (`create`/`bulkCreate`/`upsert`/`update`, `formatOutput`), `@objectstack/objectql` (optimistic locking via `updated_at`, `sys_metadata` writes), report/analytics date bucketing, and any out-of-tree consumer of `created_at`/`updated_at` (notably the objectos kernel freshness probe).

packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,8 +462,11 @@ declareDialectCell(PG_CELL, 'date wire form (#11389)', (cell) => {
462462
// SQL NULL survives as null; the year-boundary element is where a
463463
// pre-fix skew changed the year.
464464
expect(row.ds).toEqual([DAY, null, NEW_YEAR]);
465-
// Untouched on purpose: an instant is exactly what a Date is for, and
466-
// `Field.datetime` depends on it.
465+
// Untouched on purpose (ADR-0053 D-F2): the CLIENT parser keeps
466+
// materialising an instant as a `Date`, and the driver folds it to the
467+
// canonical text at its own read doors, not here. This is a raw
468+
// `execute`, past every read-side presentation, so the client's shape
469+
// is what comes back — the #13973 ruling forbids changing that parser.
467470
expect(row.ts instanceof Date).toBe(true);
468471
expect((row.ts as Date).toISOString()).toBe(`${DAY}T00:00:00.000Z`);
469472
});

0 commit comments

Comments
 (0)