|
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). |
| 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). |
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`. |
@@ -574,3 +574,78 @@ The same caveat as Postgres applies and is worth stating plainly: the migration |
574 | 574 | Regression cover is `sql-driver-datetime-mysql-storage.test.ts`, opt-in via |
575 | 575 | `OS_TEST_MYSQL_URL` (CI provisions no server), asserting a non-UTC server so it |
576 | 576 | cannot pass vacuously. 10 of its 13 cases fail without this change. |
| 577 | + |
| 578 | +--- |
| 579 | + |
| 580 | +## Addendum (2026-07-30) — `Field.time` gets the same storage convention (D-C1..D-C3, #3994) |
| 581 | + |
| 582 | +`Field.time` was the last temporal field type with **no storage convention at |
| 583 | +all** — the exact meta-problem #3912 exposed for `Field.datetime`. `formatInput` |
| 584 | +never touched it, `coerceFilterValue` returned its comparands unchanged, and the |
| 585 | +read paths papered over the drift so well (`toTimeOnly`) that `find()` always |
| 586 | +looked right. Measured on real servers (SQLite; PG 16 at `Asia/Shanghai`; |
| 587 | +MariaDB 10.11 / MySQL 8.0 at `+08:00`; Node at `America/New_York`), the same |
| 588 | +failure family followed: a business-hours window filter silently dropped 4 of 7 |
| 589 | +rows on SQLite (INTEGER epoch rows fail `>= '09:00'` because `INTEGER < TEXT`; |
| 590 | +full-timestamp text fails `<= '18:00'` lexicographically), ORDER BY put 14:30 |
| 591 | +before 08:00, a full-ISO write failed the statement outright on PG **and** |
| 592 | +MySQL, a bound `Date` stored a process-timezone wall clock on pg, MySQL's bare |
| 593 | +`TIME` rounded `…00.500` up to `…01`, and a `NOW()` default resolved against |
| 594 | +three different clocks on the three dialects. |
| 595 | + |
| 596 | +### D-C1 — The canonical form is `HH:MM:SS`, `.fff` only when non-zero |
| 597 | + |
| 598 | +A `Field.time` is a **timezone-naive wall-clock time-of-day** (#2004), so the |
| 599 | +canonical text carries no zone: `HH:MM:SS`, extended to `HH:MM:SS.fff` exactly |
| 600 | +when the milliseconds are non-zero. One function (`canonicalTimeOfDay`) produces |
| 601 | +it and is applied on write (`formatInput`), to filter comparands |
| 602 | +(`coerceFilterValue`/`temporalFilterValue`) and on read (`toTimeOnly`), the |
| 603 | +D-B1 construction transplanted. |
| 604 | + |
| 605 | +Why variable-width rather than datetime's fixed `.sss`: `.` sorts below every |
| 606 | +digit, so lexicographic order is still chronological order across mixed widths |
| 607 | +(`'14:30:00.100' < '14:30:01'`), and the zero-millisecond spelling `HH:MM:SS` |
| 608 | +is what every dialect's native TIME emits — the field-zoo `f_time` round-trip |
| 609 | +(#2022) keeps holding byte-for-byte. Determinism is what matters for equality |
| 610 | +and `distinct()`, and each wall clock has exactly one spelling. A minutes-only |
| 611 | +`'14:30'` completes to `'14:30:00'`. |
| 612 | + |
| 613 | +A `Date` / epoch-ms / full-timestamp value folds to its **UTC** time-of-day — |
| 614 | +matching the platform's instant semantics, the SQLite read repair's historical |
| 615 | +behaviour, and `nowColumnDefault`; never the process or server timezone. |
| 616 | +Uninterpretable values (including out-of-range wall clocks like `'25:00'`) pass |
| 617 | +through untouched. |
| 618 | + |
| 619 | +### D-C2 — Physical form per dialect |
| 620 | + |
| 621 | +- **SQLite**: canonical TEXT (no native type). Legacy columns converge at |
| 622 | + schema sync via `backfillCanonicalTimes` — one `UPDATE` per column whose SET |
| 623 | + expression IS the read-repair SQL (`sqliteCanonicalTimeSql`), `IS NOT` guard, |
| 624 | + log-and-swallow failure policy, `os migrate plan` row-counted entry |
| 625 | + (`normalize_time_storage`) — D-B3 verbatim. Until it runs, filters wrap the |
| 626 | + column in the repair expression: correct, just unindexed. |
| 627 | +- **Postgres**: native `time` (µs precision). The canonical literal binds |
| 628 | + unambiguously; the pre-fix failures were the *input* shapes, not the column. |
| 629 | +- **MySQL**: `TIME(3)` — the `DATETIME(3)` precedent applied: bare `TIME` is |
| 630 | + zero-precision and **rounds** fractional literals, changing the stored wall |
| 631 | + clock. Legacy `TIME(0)` columns widen at schema sync |
| 632 | + (`migrateMysqlTimeColumns`, plan kind `widen_time_columns`), restating a |
| 633 | + `NOW()` expression default where declared. |
| 634 | + |
| 635 | +### D-C3 — `NOW()` defaults are the UTC wall clock on every dialect |
| 636 | + |
| 637 | +`knex.fn.now()` compiles to `CURRENT_TIMESTAMP`, which a TIME column resolves |
| 638 | +in the **server's** zone on Postgres and the **inserting session's** zone on |
| 639 | +MySQL (so the same column's default depended on who inserted). MySQL 8.0 |
| 640 | +additionally rejects a plain `CURRENT_TIMESTAMP` default on TIME. The defaults |
| 641 | +are now expression-spelled per dialect — `strftime('%H:%M:%f','now')` with a |
| 642 | +canonical `.000` trim on SQLite, `timezone('utc', now())::time(3)` on Postgres, |
| 643 | +`(cast(utc_timestamp(3) as time(3)))` on MySQL (8.0.13+/MariaDB 10.2+ for the |
| 644 | +expression-default syntax) — all reading the UTC clock. |
| 645 | + |
| 646 | +The D-B3 caveat applies unchanged: the backfill converges *shapes*; a wall |
| 647 | +clock the old path never recorded correctly (a pg `Date` bind serialised in the |
| 648 | +host's zone) is not recoverable. Regression cover: |
| 649 | +`sql-driver-time-canonical-storage.test.ts`, `sql-driver-time-of-day.test.ts` |
| 650 | +(SQLite), `sql-driver-time-live-dialects.test.ts` (live PG + MySQL, in the CI |
| 651 | +temporal-conformance job's non-UTC matrix). |
0 commit comments