|
1 | 1 | # ADR-0053: `date` is a timezone-naive calendar day; `datetime` is an instant rendered in a reference timezone |
2 | 2 |
|
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). |
4 | 4 | **Deciders**: ObjectStack Protocol Architects |
5 | 5 | **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) |
6 | 6 | **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 |
1020 | 1020 | dissolved on 2026-08-11. The conversion itself is pinned by |
1021 | 1021 | `mongodb-time-storage.test.ts`, which is unaffected: it is pure, needs no |
1022 | 1022 | 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. |
0 commit comments