|
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). **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). |
| 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). **Corrected 2026-09-08 (#16609 / PR #16716):** that exception is gone — `findWithWindowFunctions` now routes each row through the same `formatOutput` pass, so those two column classes present as the same canonical text there, with the window ALIAS columns carved out (a computed alias wins the key and its value stays raw). D-F1 rules those two classes and no more: the other presentations that pass applies at that door are #16609's contract, not this ADR's. |
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`. |
@@ -1078,8 +1078,21 @@ Postgres and MySQL alike, exactly as SQLite always did: |
1078 | 1078 |
|
1079 | 1079 | None of these doors hands out a JS `Date` for these columns, save the one |
1080 | 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: |
| 1081 | +through unchanged. ~~`findWithWindowFunctions` is not one of these doors (see |
| 1082 | +Consequences; #16609).~~ — **corrected 2026-09-08 (#16609 / PR #16716): it is |
| 1083 | +one of them now, for these two column classes.** It routes each row through the |
| 1084 | +same `formatOutput` pass `find()` runs, minus the window ALIAS columns: a |
| 1085 | +computed alias wins the key and its value stays raw (`select *` plus an |
| 1086 | +`... as ok` window projection yields two `ok` columns and the row object keeps |
| 1087 | +the LAST), so no declared field's presentation rule is ever applied to a |
| 1088 | +computed value. That pass moves more than this addendum rules — the |
| 1089 | +`external.columnMap` row-KEY rename, `Field.object`/JSON, numeric strings, |
| 1090 | +`Field.boolean`, `Field.date` and `Field.time` — and D-F1 governs, at that door |
| 1091 | +exactly as at the doors listed above, ONLY the two instant classes named here; |
| 1092 | +the rest of that pass is #16609's contract, pinned by |
| 1093 | +`sql-driver-window-function-output.test.ts` (door-to-door agreement with |
| 1094 | +`find()` on SQLite; the Postgres and MySQL cells under `Temporal Conformance |
| 1095 | +(live PG + MySQL)`). Declared = enforced: |
1083 | 1096 | `sql-driver-13973-canonical-iso-read-door.test.ts` asserts it per cell of the |
1084 | 1097 | D-A3 driver axis for every door listed — `bulkCreate()` over the rows a |
1085 | 1098 | dialect's bulk insert returns (MySQL, with no `RETURNING`, returns none, and |
@@ -1154,9 +1167,17 @@ itself. |
1154 | 1167 | `Date` arm sits beside it. |
1155 | 1168 | - D-A3's matrix gains the read-shape cell. `Temporal Conformance (live PG + |
1156 | 1169 | MySQL)` is the job that proves it; its package set is not widened. |
1157 | | -- Not covered: `findWithWindowFunctions`, which applies no read presentation of |
| 1170 | +- ~~Not covered: `findWithWindowFunctions`, which applies no read presentation of |
1158 | 1171 | any kind today (booleans, dates and JSON included) — a pre-existing gap of its |
1159 | | - own, recorded rather than folded in. |
| 1172 | + own, recorded rather than folded in.~~ — **corrected 2026-09-08 (#16609 / PR |
| 1173 | + #16716): that gap was a card of its own and it landed.** The door now runs |
| 1174 | + each row through the same `formatOutput` pass `find()` runs, so it presents |
| 1175 | + the two instant classes D-F1 rules exactly as the doors listed above do — and |
| 1176 | + also the classes D-F1 does NOT rule (the `external.columnMap` row-KEY rename, |
| 1177 | + `Field.object`/JSON, numeric strings, `Field.boolean`, `Field.date`, |
| 1178 | + `Field.time`), which are #16609's contract rather than this ADR's. Carved out |
| 1179 | + at that door: the window ALIAS columns — a computed alias wins the key and its |
| 1180 | + value stays raw. |
1160 | 1181 |
|
1161 | 1182 | ### Options not taken |
1162 | 1183 |
|
|
0 commit comments