|
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 (`temporalFilterValue` promotion onto the `IDataDriver` contract) still open as the ADR predicted. |
| 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 (`temporalFilterValue` promotion onto the `IDataDriver` contract) still open as the ADR predicted. **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). |
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`. |
@@ -402,3 +402,160 @@ time, the matrix proves runtime correctness across drivers. |
402 | 402 | inventing a semantic" stance. No change to Phase 2's reference-timezone plan. |
403 | 403 | - Until D-A2 lands, the hook depends on a duck-typed driver method — a known, |
404 | 404 | intentionally-temporary seam tracked here. |
| 405 | + |
| 406 | +--- |
| 407 | + |
| 408 | +## Addendum (2026-07-29) — `Field.datetime` has ONE storage form per dialect, always a UTC instant |
| 409 | + |
| 410 | +> **Status:** landed. This addendum **revises** the storage half of Phase 1 (which |
| 411 | +> left `Field.datetime` "stored as UTC epoch ms" on SQLite) and **extends** |
| 412 | +> D-A1 from the 2026-06-18 addendum to the column side of the comparison. |
| 413 | +> Closes #3912; supersedes the epoch-ms convention referenced there. |
| 414 | +
|
| 415 | +### What the epoch-ms convention actually was |
| 416 | + |
| 417 | +It was never a convention — it was a description of what better-sqlite3 happened |
| 418 | +to do with a bound JS `Date`. Nothing enforced it: `formatInput` deliberately left |
| 419 | +`datetime` untouched, so the storage form was decided by whichever writer got |
| 420 | +there first. |
| 421 | + |
| 422 | +- A JS `Date` (seed loader, import, server-side code) → INTEGER epoch ms. |
| 423 | +- A REST/JSON write → ISO **TEXT**, because JSON has no `Date` type. |
| 424 | +- A `defaultValue: 'NOW()'` slot → ISO TEXT. |
| 425 | +- The platform's own `created_at` / `updated_at` → ISO TEXT (they are stamped |
| 426 | + with `toISOString()`), on **every** object in the system. |
| 427 | + |
| 428 | +One column therefore held both forms at once, while the read path coerced filter |
| 429 | +comparands to epoch ms purely from the DECLARED type. On SQLite's type ordering |
| 430 | +(`INTEGER < TEXT`) that made a two-sided window collapse to zero rows and a |
| 431 | +one-sided `>=` match every TEXT row regardless of the bound — the reported symptom |
| 432 | +in #3912 (a dashboard `last_30_days` reading 0 with 29 rows in range). It also |
| 433 | +left `ORDER BY` sorting all INTEGER rows before all TEXT ones (#3928). |
| 434 | + |
| 435 | +### D-B1 — The canonical storage form is `YYYY-MM-DDTHH:MM:SS.sssZ` |
| 436 | + |
| 437 | +`Field.datetime` is stored as fixed-width, zone-explicit UTC text on SQLite, and |
| 438 | +written to Postgres/MySQL as that same string. `canonicalUtcDatetime()` is the one |
| 439 | +function that produces it, applied on write (`formatInput`) and to every filter |
| 440 | +comparand (`coerceFilterValue`) so the two sides of a comparison cannot disagree |
| 441 | +about shape. |
| 442 | + |
| 443 | +Chosen over the epoch integer because: |
| 444 | + |
| 445 | +- Lexicographic order **is** chronological order, so range filters and `ORDER BY` |
| 446 | + read the column directly and can use an index. An epoch convention forces an |
| 447 | + expression wrapper on every temporal predicate — it moves the cost rather than |
| 448 | + removing it. |
| 449 | +- `strftime`/`julianday` parse it, so the date-bucket expression needs no |
| 450 | + epoch↔text CASE (#3773). |
| 451 | +- It is what `formatOutput` already presents, so storage and presentation stop |
| 452 | + disagreeing — the asymmetry Phase 1 removed for `date`, now removed for |
| 453 | + `datetime`. |
| 454 | +- It matches the `Field.date` convention, so the platform has one temporal |
| 455 | + storage story instead of one per field type. |
| 456 | +- It was already the majority of rows on disk, so the migration is the smaller |
| 457 | + one. |
| 458 | + |
| 459 | +### D-B2 — The comparand rule is dialect-INDEPENDENT, which fixes Postgres too |
| 460 | + |
| 461 | +`coerceFilterValue` previously canonicalised only on SQLite and passed the value |
| 462 | +through on native-timestamp dialects. That was not neutral. Measured against |
| 463 | +PostgreSQL 16 with `TimeZone = Asia/Shanghai`: |
| 464 | + |
| 465 | +- A zone-**naive** write (`'2026-03-20 12:00:00'`) bound into `timestamptz` was |
| 466 | + resolved against the SERVER's timezone and stored as `2026-03-20T04:00:00Z` — |
| 467 | + 8 hours off the instant SQLite records for the same write, in direct violation |
| 468 | + of this ADR's "a naive wall clock is UTC" rule. |
| 469 | +- A bare `YYYY-MM-DD` comparand (what a `{30_days_ago}` token expands to) meant |
| 470 | + midnight in the SERVER's timezone, so the identical query over the identical |
| 471 | + instant put a row on a **different calendar day** than it did on SQLite. |
| 472 | + |
| 473 | +Stating the `Z` on both sides removes the server's timezone from the answer. |
| 474 | +Postgres storage itself needed no change — knex's `table.timestamp` already |
| 475 | +creates `timestamptz` (`useTz` defaults true, verified) — so this is a write- and |
| 476 | +comparand-side fix there, not a migration. Regression cover is |
| 477 | +`sql-driver-datetime-postgres-timezone.test.ts`, opt-in via `OS_TEST_POSTGRES_URL` |
| 478 | +because CI provisions no server; it asserts it is pointed at a non-UTC server so |
| 479 | +it cannot pass vacuously. |
| 480 | + |
| 481 | +### D-B3 — Existing rows converge at schema sync; correctness never depends on it |
| 482 | + |
| 483 | +`backfillCanonicalDatetimes` runs inside `initObjects`, rewriting SQLite rows that |
| 484 | +are not already canonical (INTEGER/REAL epoch, zone-naive text, offset-bearing |
| 485 | +text). It is idempotent, skips freshly-created tables entirely, and preserves |
| 486 | +values SQLite cannot parse rather than nulling them. |
| 487 | + |
| 488 | +It is allowed to fail. On error it logs and marks nothing, and the read paths keep |
| 489 | +the `sqliteCanonicalDatetimeSql` repair that makes an un-migrated column compare |
| 490 | +and bucket correctly — just without an index. A migration must never be able to |
| 491 | +take boot down, and query correctness must never be contingent on one having run. |
| 492 | +The corollary is that the repair expression stays in the codebase permanently: it |
| 493 | +also covers external/unmanaged tables (ADR-0015), which never get a backfill. |
| 494 | + |
| 495 | +`needsLegacyDatetimeRepair` is the single predicate every read path asks, so the |
| 496 | +filter, bucket and analytics surfaces cannot drift apart about whether a column |
| 497 | +is clean. |
| 498 | + |
| 499 | +### Consequences |
| 500 | + |
| 501 | +- D-A2 (promote `temporalFilterValue` onto the `IDataDriver` contract) now has a |
| 502 | + second method to carry with it: `temporalFilterColumnSql`, the column-side |
| 503 | + companion threaded to analytics as |
| 504 | + `StrategyContext.coerceTemporalFilterColumn`. Coercing the value alone is not |
| 505 | + sufficient on a mixed-form column, so any surface that binds a comparand into |
| 506 | + raw SQL must wrap its column reference too. Both remain duck-typed until D-A2. |
| 507 | +- D-A3's conformance matrix should gain a **storage-form** axis (canonical, |
| 508 | + legacy-epoch, legacy-naive) and a **server-timezone** axis, since both are now |
| 509 | + known to have produced dialect-divergent row results. |
| 510 | +- #3928 (datetime `ORDER BY` mis-sorted on mixed storage) is closed by |
| 511 | + construction rather than by a sort-side fix. |
| 512 | +### D-B4 — MySQL stores the same instant, spelled the way MySQL parses it |
| 513 | + |
| 514 | +MySQL was the third dialect, and measurement (MariaDB 10.11, `default_time_zone |
| 515 | += '+08:00'`, process on `America/New_York`) found it the worst off — including |
| 516 | +one defect D-B1 *introduced*: |
| 517 | + |
| 518 | +- MySQL accepts neither the `T` separator nor the `Z` suffix in a datetime |
| 519 | + literal, so the canonical form failed the statement outright with *Incorrect |
| 520 | + datetime value*. An ISO comparand or REST/JSON write had **always** failed this |
| 521 | + way; canonicalising the write path extended it to `Date` writes too. |
| 522 | +- `table.timestamp` emits MySQL `TIMESTAMP`: a 32-bit epoch that cannot hold an |
| 523 | + instant outside 1970..2038 (a contract end date in 2040 was rejected), carries |
| 524 | + no fractional digits so the canonical form's milliseconds were truncated, and |
| 525 | + converts on read/write using the session timezone. |
| 526 | +- mysql2's `connection.timezone` defaults to the HOST's local zone and |
| 527 | + `@@session.time_zone` to the server's, so a zone-naive value landed 12 hours |
| 528 | + off with the two misconfigurations compounding. |
| 529 | + |
| 530 | +The resolution keeps the logical canon and changes only the physical spelling: |
| 531 | + |
| 532 | +1. `Field.datetime` maps to **`DATETIME(3)`** — range 1000..9999, milliseconds |
| 533 | + kept, and no timezone conversion of its own, so the column holds the UTC wall |
| 534 | + clock the driver writes. This is the ServiceNow model. Postgres deliberately |
| 535 | + keeps `timestamptz`: asking for precision 3 there would *reduce* it from |
| 536 | + microseconds. The builtin `created_at`/`updated_at` take the same type — the |
| 537 | + registry declares them `Field.datetime`, and they are what most list views |
| 538 | + sort by. |
| 539 | +2. The connection is **pinned to UTC on both layers** — `connection.timezone = |
| 540 | + 'Z'` for mysql2 and `SET time_zone = '+00:00'` via `pool.afterCreate` for the |
| 541 | + server. This is what makes the wall clock *be* the instant, and it keeps a |
| 542 | + not-yet-migrated `TIMESTAMP` column correct too. An explicit host choice is |
| 543 | + left alone; an existing `afterCreate` is chained, not replaced. |
| 544 | +3. `storageDatetimeValue` respells the canonical instant as a MySQL literal for |
| 545 | + the bind, on the write path and the filter path alike. Deliberately strict: |
| 546 | + only an exactly-canonical string is rewritten, so an unparseable value or a |
| 547 | + year outside 1000..9999 reaches MySQL untouched and fails loudly. |
| 548 | + |
| 549 | +`migrateMysqlDatetimeColumns` widens legacy `TIMESTAMP` columns at schema sync, |
| 550 | +under the same failure policy as D-B3 — a `TIMESTAMP` column keeps *working* |
| 551 | +(the driver binds the same literal and the session is UTC), it merely keeps the |
| 552 | +range and precision limits. Verified on a real server: the ALTER moves no stored |
| 553 | +instant, correctly-stored legacy rows round-trip exactly, and it is idempotent. |
| 554 | + |
| 555 | +The same caveat as Postgres applies and is worth stating plainly: the migration |
| 556 | +**cannot repair instants the old timezone-ambiguous write path recorded wrongly** |
| 557 | +— that information is gone. It preserves what is on disk. |
| 558 | + |
| 559 | +Regression cover is `sql-driver-datetime-mysql-storage.test.ts`, opt-in via |
| 560 | +`OS_TEST_MYSQL_URL` (CI provisions no server), asserting a non-UTC server so it |
| 561 | +cannot pass vacuously. 10 of its 13 cases fail without this change. |
0 commit comments