Skip to content

Commit 07f93e3

Browse files
authored
docs(adr): ADR-0053 — Phase 2 detailed design (reference-timezone model) (#1975)
1 parent 086b3c9 commit 07f93e3

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,156 @@ rendering.
179179
Rejected as a complete fix: it helps the formula path but not the
180180
write/filter asymmetry, and it bakes in UTC midnight (Phase 2's tz-aware
181181
`today()` supersedes it).
182+
183+
---
184+
185+
## Phase 2 — Detailed Design (implementation plan)
186+
187+
> **Status:** Phase 1 landed in `@objectstack/driver-sql` (PR #1968) — `Field.date`
188+
> is now a tz-naive `YYYY-MM-DD` string at the write/read boundary. This section
189+
> details Phase 2 (the reference-timezone model) for review. It is the plan, not
190+
> yet a commitment to ship; each slice below is independently revertable and gated
191+
> behind "reference timezone unset → UTC" (today's behavior).
192+
193+
### Reframe: two axes, two sources, two risk profiles
194+
195+
ADR-0053's "timezone model" is, in the code, **two distinct concerns** that must
196+
be solved separately. Conflating them in one change is the chief risk of Phase 2.
197+
198+
| Axis | Question it answers | Source | Boundary |
199+
|------|--------------------|--------|----------|
200+
| **Compute-tz** | "what calendar day is it / which bucket does this fall in?" — `today()`/`daysFromNow()`, analytics bucketing, cron day-boundaries | the **execution context** (user / org / job) | threaded *into* evaluation |
201+
| **Render-tz** | "show this UTC instant in the viewer's zone" — `datetime` display | the **viewer** | applied at the *presentation* boundary; storage stays UTC |
202+
203+
Everything below is organized along this split.
204+
205+
### The reference-timezone resolver
206+
207+
A single resolver computes the active timezone for an execution context, with this
208+
precedence:
209+
210+
| Layer | Stored where | Read path |
211+
|-------|--------------|-----------|
212+
| User override | `sys-user-preference` (`key='timezone'`) — today a generic k/v store that the engine never reads (`sys-user-preference.object.ts`) | by `(user_id, key)` |
213+
| Org default | a **`tenant`-scoped settings manifest** (new; e.g. `localization`/`timezone`) | `service-settings` — tenant scope is keyed by `ExecutionContext.tenantId` = `activeOrganizationId`, which **is** the org under one-org-per-environment (ADR-0002), so no new scope or schema migration is needed. The settings reactive client (`settings-service.ts` `createClient`) gives a cheap in-memory snapshot. |
214+
| Fallback || `'UTC'` (flag-off default; identical to today) |
215+
216+
**Where it resolves — one chokepoint.** Fold the resolution into
217+
`resolveExecutionContext` (`runtime/.../resolve-execution-context.ts`), which already
218+
queries `sys_member` / permission-sets per request; reading one user-pref + one
219+
settings snapshot there is consistent and benefits from any future caching. Add
220+
**`timezone?: string` to `ExecutionContextSchema`** (`spec/.../execution-context.zod.ts`).
221+
The timezone is then resolved **once per request** and rides the existing context
222+
plumbing to every consumer. This is the *only* new data Phase 2 introduces.
223+
224+
**Three execution branches** (the resolver's input differs by entry path):
225+
226+
| Entry path | Identity available today | TZ source |
227+
|------------|--------------------------|-----------|
228+
| Interactive HTTP | `userId` + `tenantId` (resolved from session) | user override → org default → UTC |
229+
| Scheduled job / cron | none — runs `isSystem`, handler gets only `{ jobId, data }` | the **job's own `timezone` field** (`sys_job.timezone`, already wired through croner) |
230+
| Flow / record-change trigger | `userId` only (`AutomationContext``automation-service.ts`) | needs a `timezone` added to `AutomationContext`; org default |
231+
232+
### The shared DST-safe primitive
233+
234+
Phase 2 invents **no** new timezone math. The DST-correct pattern already exists and
235+
is proven in `service-messaging`'s `preference-resolver.ts` (`wallClockInTz` /
236+
`minutesOfDayInTz`), which uses `Intl.DateTimeFormat({ timeZone }).formatToParts()`.
237+
Extract it once — `partsInTz(instant, tz) → { y, m, d, … }` and
238+
`calendarDayUtc(instant, tz) → Date` — into a shared util consumed by formula,
239+
analytics, and rendering. Same single-source-of-truth discipline as Phase 1's
240+
`toDateOnly()` helper. **Never** hand-roll offset arithmetic (breaks across DST).
241+
242+
### The three decisions
243+
244+
**D1 — `today()`/`daysFromNow()`/`daysAgo()` return a `Date` at *UTC-midnight of the
245+
reference-tz calendar day***`new Date(Date.UTC(y, m, d))` where `(y,m,d)` are the
246+
calendar parts computed in the reference tz. **Not** a date-only string; **not**
247+
"local-midnight-as-instant".
248+
249+
This is forced by how comparison actually works. `record.due_date == today()` never
250+
compares a string to `today()`: when cel-js faults on `string <op> Timestamp`,
251+
`hydrateOverloadStrings` (`cel-engine.ts`) rehydrates the date-only field string via
252+
`Date.parse("2026-06-15")` = **UTC midnight**. So the field side is *always* a
253+
UTC-midnight `Date` at compare time. For `today()` to compare cleanly it must also be
254+
a UTC-midnight `Date` of the same calendar day. The driver filter path agrees
255+
(`coerceFilterValue`/`toDateOnly` does `getUTC*` on a `Date`), and Phase 1 stores the
256+
UTC calendar day. UTC-midnight-of-the-reference-tz-day is the *one* representation
257+
consistent with all three boundaries (CEL hydration, driver filter, Phase-1 storage).
258+
259+
> **The trap:** representing `today()` as the reference-tz *local* midnight as an
260+
> instant (e.g. `2026-06-15T07:00:00Z` for `America/Los_Angeles`) re-introduces the
261+
> exact tz-shifted instant this ADR removes, and it would **not** equal the
262+
> UTC-midnight-hydrated field — the silent-miss bug returns.
263+
264+
Bonus: making `daysFromNow(n)`/`daysAgo(n)` compute `calendarDay ± n → UTC-midnight`
265+
**also fixes the "keeps wall-clock time" defect** (`stdlib.ts:36`) for free. The
266+
`now() + duration("Nh")` escape hatch remains for genuine sub-day instants.
267+
268+
**D2 — tz-aware analytics buckets in-memory (JS), uniformly; do not emit
269+
dialect-specific `date_trunc … AT TIME ZONE`.** Keep DB-side bucketing only for the
270+
UTC/no-tz fast path. `date_trunc(… AT TIME ZONE tz)` is Postgres-only; SQLite has no
271+
timezone database and MySQL needs tz tables loaded — splitting behavior by dialect
272+
yields *different bucket boundaries on different drivers for the same data* (a
273+
correctness landmine the sqlite-heavy test matrix wouldn't catch). The two existing
274+
UTC-hardcoded JS bucketers (`service-analytics` `preview-evaluator.ts` `bucketDate`,
275+
`dimension-labels.ts` `formatDateBucket`) swap `getUTC*` for the shared `partsInTz`
276+
util — correct on every dialect, same DST-safe code as D1. The seam is ready:
277+
`dataset-executor.ts` already carries a `timezone` field (hard-coded `'UTC'`).
278+
*Accepted cost:* when tz ≠ UTC the GROUP BY can't be pushed down, so wide
279+
aggregations pull more rows. Mitigate by keeping the DB-side UTC fast path when the
280+
reference tz is unset or `'UTC'`, still pushing the date-range *filter* to the DB and
281+
only bucketing in memory, and revisiting a Postgres-only pushdown later if a real
282+
workload needs it. Ship correct-and-uniform first.
283+
284+
**D3 — wire report schedules onto the existing `CronJobAdapter`; do not delete the
285+
fields.** `sys-report-schedule.object.ts` documents `cron_expression` as "reserved
286+
for the next milestone when a cron-capable scheduler adapter is available, it wins
287+
over `interval_minutes`" — that adapter now exists (`service-job` `cron-job-adapter.ts`,
288+
croner + per-job `timezone`, already wired for `sys_job`). `report-service.ts`
289+
`advanceSchedule` still reads only `interval_minutes` and ignores both
290+
`cron_expression` and `timezone`. When `cron_expression` is present, schedule via
291+
croner with the timezone; keep `interval_minutes` as the tz-agnostic fallback. This
292+
flips two author-facing-but-runtime-dead fields to **live** in one move — the
293+
*enforce* resolution the spec-liveness gate wants (record this PR as ledger
294+
evidence). Report schedules run `SYSTEM_CTX`, so their tz source is the schedule's
295+
own `timezone` field (same model as `sys_job`) — self-contained, off the resolver's
296+
critical path.
297+
298+
### Critical blind spot to fix regardless of tz
299+
300+
`applyFormulaPlan` (`objectql` `engine.ts`, called from `find`/`findOne`) evaluates
301+
read-time formula fields with **only `{ record }`** — no `now`, no `execCtx`, no
302+
timezone. Today that means `today()` inside a formula field uses real wall-clock UTC.
303+
`find`/`findOne` already hold `opCtx.context` (the `ExecutionContext`); thread
304+
`{ now: nowSnap, timezone, user, org }` through — mirroring `applyFieldDefaults`,
305+
which already does this correctly. Worth doing on its own (pinned `now` for
306+
determinism + computed fields can reference user/org), independent of timezone.
307+
308+
### Implementation map
309+
310+
| Slice | Touch points (file) | Axis | Risk |
311+
|-------|---------------------|------|------|
312+
| 1. Resolver + `ExecutionContext.timezone` | `resolve-execution-context.ts`, `execution-context.zod.ts`, new settings manifest | plumbing | none (default UTC) |
313+
| 2. `applyFormulaPlan` context threading | `objectql/engine.ts` | compute | low |
314+
| 3. tz-aware `today()`/`daysFromNow()`/`daysAgo()` + shared `partsInTz` util | `formula/stdlib.ts`, `cel-engine.ts`, `formula/types.ts` | compute | behind flag |
315+
| 4. Render-tz in template formatters + email path | `formula/template-engine.ts` (date/datetime formatters already take `locale` → add `timeZone`); `plugin-email` rendering currently bypasses the formatter pipeline and needs routing through it | render | low blast radius (one centralized formatter) + one outlier |
316+
| 5. Analytics bucket tz | `service-analytics` `preview-evaluator.ts`, `dimension-labels.ts`, `dataset-executor.ts` | compute | highest (dialect — see D2) |
317+
| 6. Report schedule → croner+tz | `plugin-reports/report-service.ts` | compute | low; doubles as liveness cleanup |
318+
319+
Cron day-boundaries (`sys_job`) need **no change** — already tz-wired via croner.
320+
321+
### Open prerequisites
322+
323+
- **Confirm cel-js supports `duration()` + Timestamp arithmetic** (the documented
324+
`now() + duration("Nh")` escape hatch). No `duration` usage exists in the formula
325+
package today; if unsupported this is a small prerequisite patch.
326+
- **Email rendering is architectural, not a parameter.** `plugin-email` renders via a
327+
naive `String()` path with no formatter/locale/tz; routing it through the formula
328+
template engine (or porting the formatters) is the real cost in slice 4.
329+
330+
### Rollback
331+
332+
Every slice is feature-flaggable behind "reference timezone unset → UTC". With no org
333+
reference timezone configured, the resolver returns `'UTC'` and all compute/render
334+
paths are byte-for-byte today's behavior — the safe default and the rollback target.

0 commit comments

Comments
 (0)