From 4d97bec3c0e26e9f5851f53e824ef6832b81218b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 10:21:55 +0800 Subject: [PATCH] feat(formula): timezone-aware today()/daysFromNow()/daysAgo() (#1980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0053 Phase 2 D1. today()/daysFromNow()/daysAgo() become calendar-day functions resolved in a reference timezone (threaded ExecutionContext.timezone -> EvalContext.timezone -> CEL stdlib). Each returns the reference-tz calendar day as a UTC-midnight Date — the representation consistent with Field.date hydration, the SQL driver's date-filter normalization, and Phase 1 storage — so `record.date == daysFromNow(n)` matches in-memory too. Timezone math uses Intl.DateTimeFormat (DST-safe). BEHAVIOR CHANGE: daysFromNow(n)/daysAgo(n) previously kept now's wall-clock time; they now drop it and return the calendar day at midnight (the ADR "defect #3" fix). today() is unchanged at UTC. Sub-day offsets: now() + duration("Nh"). objectql threads execCtx.timezone into applyFormulaPlan (read-time formula fields) and applyFieldDefaults (default-value expressions). With no reference tz configured the zone is UTC. Tests: tz-aware today()/daysFromNow()/daysAgo() (America/New_York day boundary); daysFromNow midnight semantics; existing wall-clock assertions updated to calendar-day. formula 121 green, objectql 639 green. Part of #1980. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/tz-aware-time-functions.md | 14 ++++++++ packages/formula/src/cel-engine.test.ts | 21 ++++++++++-- packages/formula/src/cel-engine.ts | 6 ++-- packages/formula/src/seed-eval.test.ts | 8 +++-- packages/formula/src/stdlib.ts | 45 +++++++++++++++++++++++-- packages/formula/src/types.ts | 6 ++++ packages/objectql/src/engine.ts | 4 ++- 7 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 .changeset/tz-aware-time-functions.md diff --git a/.changeset/tz-aware-time-functions.md b/.changeset/tz-aware-time-functions.md new file mode 100644 index 0000000000..7c403c1c54 --- /dev/null +++ b/.changeset/tz-aware-time-functions.md @@ -0,0 +1,14 @@ +--- +"@objectstack/formula": minor +"@objectstack/objectql": minor +--- + +feat(formula): timezone-aware `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2) + +These are now **calendar-day** functions resolved in a reference timezone, threaded from `ExecutionContext.timezone` (#1978) through `EvalContext.timezone` into the CEL stdlib. Each returns the reference-tz calendar day expressed as a **UTC-midnight `Date`** (ADR-0053 decision D1) — the one representation consistent with how `Field.date` strings hydrate, how the SQL driver normalizes date filters, and how Phase 1 stores dates. So `record.close_date == daysFromNow(30)` now matches in-memory too, not just at the storage boundary. The timezone calculation uses `Intl.DateTimeFormat` (DST-safe; no hand-rolled offset math). + +**⚠️ Behavior change:** `daysFromNow(n)` / `daysAgo(n)` previously kept the wall-clock time of `now` (e.g. `daysFromNow(30)` at `10:00Z` → `…T10:00:00Z`). They now drop the time and return the calendar day at **midnight** (`…T00:00:00Z`) — the ADR-0053 "defect #3" fix. `today()` is unchanged at UTC (it already truncated to start-of-day). For a genuine sub-day offset use the documented escape hatch `now() + duration("Nh")`. + +With no reference timezone configured the zone resolves to `UTC`, so `today()` is byte-for-byte unchanged; only the `daysFromNow`/`daysAgo` midnight-truncation differs from before. `objectql` threads `execCtx.timezone` into read-time formula evaluation (`applyFormulaPlan`) and default-value expressions (`applyFieldDefaults`). + +Part of #1980. (Consuming a non-UTC reference timezone end-to-end also needs the `localization` settings manifest noted in #1978.) diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index ba7955cce4..2a7bf275ea 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -40,11 +40,28 @@ describe('celEngine', () => { if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z'); }); - it('daysFromNow(n) advances by n days from pinned now', () => { + it('daysFromNow(n) returns the calendar day n days out, at midnight (ADR-0053 D1)', () => { + // Calendar-day semantics: the wall-clock time of `now` is dropped, so + // `record.date == daysFromNow(n)` matches in-memory (the defect-3 fix). const pinned = new Date('2026-01-15T10:00:00Z'); const r = celEngine.evaluate(cel('daysFromNow(30)'), { now: pinned }); expect(r.ok).toBe(true); - if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T10:00:00.000Z'); + if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z'); + }); + + it('today()/daysFromNow()/daysAgo() resolve the calendar day in the reference timezone', () => { + // 2026-01-15T02:00Z is still Jan 14 in America/New_York (UTC-5). + const pinned = new Date('2026-01-15T02:00:00Z'); + const tz = 'America/New_York'; + const today = celEngine.evaluate(cel('today()'), { now: pinned, timezone: tz }); + expect(today.ok && (today.value as Date).toISOString()).toBe('2026-01-14T00:00:00.000Z'); + const tomorrow = celEngine.evaluate(cel('daysFromNow(1)'), { now: pinned, timezone: tz }); + expect(tomorrow.ok && (tomorrow.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z'); + const yesterday = celEngine.evaluate(cel('daysAgo(1)'), { now: pinned, timezone: tz }); + expect(yesterday.ok && (yesterday.value as Date).toISOString()).toBe('2026-01-13T00:00:00.000Z'); + // Default (UTC) sees Jan 15. + const utc = celEngine.evaluate(cel('today()'), { now: pinned }); + expect(utc.ok && (utc.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z'); }); it('classifies parse errors with kind=parse', () => { diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index e2f2abd44a..2102a7ca21 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -32,13 +32,13 @@ export const DEFAULT_LIMITS = { maxCallArguments: 16, } as const; -function buildEnv(now: () => Date): Environment { +function buildEnv(now: () => Date, timezone = 'UTC'): Environment { const env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true, limits: DEFAULT_LIMITS, }); - return registerNumericCoercions(registerStdLib(env, now)); + return registerNumericCoercions(registerStdLib(env, now, timezone)); } /** @@ -272,7 +272,7 @@ export const celEngine: DialectEngine = { const now = () => ctx.now ?? new Date(); try { - const env = buildEnv(now); + const env = buildEnv(now, ctx.timezone ?? 'UTC'); const scope = buildScope(ctx); try { const raw = env.evaluate(source, scope); diff --git a/packages/formula/src/seed-eval.test.ts b/packages/formula/src/seed-eval.test.ts index 17d8504a04..b4c1baf937 100644 --- a/packages/formula/src/seed-eval.test.ts +++ b/packages/formula/src/seed-eval.test.ts @@ -23,7 +23,9 @@ describe('resolveSeed', () => { const pinned = new Date('2026-01-15T10:00:00Z'); const r = resolveSeed(cel('daysFromNow(30)'), { now: pinned }); expect(r.ok).toBe(true); - if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T10:00:00.000Z'); + // Calendar-day semantics (ADR-0053 D1): the calendar day 30 days out, at + // midnight — the wall-clock time of `now` is dropped. + if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z'); }); it('walks arrays', () => { @@ -77,7 +79,9 @@ describe('resolveSeed', () => { { now: pinned }, ); expect(r.ok).toBe(true); + // The pinned now's calendar day is honored (Jun 1 + 30d = Jul 1); the time + // is dropped — daysFromNow is a calendar-day function (ADR-0053 D1). if (r.ok) - expect((r.value.close_date as Date).toISOString()).toBe('2026-07-01T12:00:00.000Z'); + expect((r.value.close_date as Date).toISOString()).toBe('2026-07-01T00:00:00.000Z'); }); }); diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index f7fddb7ad7..88540ae4f4 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -15,6 +15,40 @@ import type { Environment } from '@marcbachmann/cel-js'; import type { EvalContext } from './types'; +/** + * Calendar-day parts (y/m/d) of an instant *as seen in a timezone* + * (ADR-0053 Phase 2). Uses `Intl.DateTimeFormat` so DST transitions are + * handled correctly — never hand-rolled offset math. An unknown zone throws, + * which the caller treats as a fall-through to UTC. + */ +function partsInTz(d: Date, tz: string): { y: number; m: number; day: number } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(d); + const get = (t: string) => Number(parts.find((p) => p.type === t)?.value); + return { y: get('year'), m: get('month'), day: get('day') }; +} + +/** + * The calendar day of an instant *in a reference timezone*, expressed as a + * UTC-midnight `Date` (ADR-0053 Phase 2, decision D1). This is the one + * representation consistent with how `Field.date` strings hydrate (UTC + * midnight), how the SQL driver normalizes date filters, and how Phase 1 + * stores dates — so `record.date == today()` compares cleanly. Falls back to + * the UTC calendar day for `UTC` or an invalid zone. + */ +function calendarDayUtc(d: Date, tz: string): Date { + if (tz && tz !== 'UTC') { + try { + const { y, m, day } = partsInTz(d, tz); + return new Date(Date.UTC(y, m - 1, day)); + } catch { + // unknown zone → fall through to UTC + } + } + return startOfDayUtc(d); +} + /** Truncate a Date to start-of-day in UTC. */ function startOfDayUtc(d: Date): Date { const out = new Date(d.getTime()); @@ -50,20 +84,25 @@ function addDaysUtc(d: Date, n: number): Date { export function registerStdLib( env: Environment, now: () => Date, + timezone = 'UTC', ): Environment { + // `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they + // resolve to the reference-tz calendar day expressed as a UTC-midnight Date + // (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a + // genuine sub-day offset use `now() + duration("Nh")`. return env .registerFunction('now(): google.protobuf.Timestamp', () => now()) .registerFunction( 'today(): google.protobuf.Timestamp', - () => startOfDayUtc(now()), + () => calendarDayUtc(now(), timezone), ) .registerFunction( 'daysFromNow(int): google.protobuf.Timestamp', - (n: bigint | number) => addDaysUtc(now(), Number(n)), + (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)), ) .registerFunction( 'daysAgo(int): google.protobuf.Timestamp', - (n: bigint | number) => addDaysUtc(now(), -Number(n)), + (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)), ) // Returns true when `value` is null, undefined, empty string, or empty list. // Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic. diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index c11e1eddaf..b8c36e4b96 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -23,6 +23,12 @@ import type { Expression } from '@objectstack/spec'; export interface EvalContext { /** Logical "now" snapshot — pinned per evaluation run for determinism. */ now?: Date; + /** + * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day + * functions `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2). + * Defaults to `UTC` when unset. + */ + timezone?: string; /** Current authenticated subject (hook / action / view contexts). */ user?: { id: string; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 39142a709c..8894e1d71c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -108,12 +108,13 @@ function applyFormulaPlan( ): void { if (!plan.length) return; const now = nowSnapshot ?? new Date(); + const timezone = execCtx?.timezone; const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined; const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined; for (const rec of records) { if (rec == null) continue; for (const fp of plan) { - const r = ExpressionEngine.evaluate(fp.expression, { now, user, org, record: rec }); + const r = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, record: rec }); rec[fp.name] = r.ok ? r.value : null; } } @@ -761,6 +762,7 @@ export class ObjectQL implements IDataEngine { if (typeof dv === 'object' && dv !== null && (dv as any).dialect && typeof (dv as any).source === 'string') { const result = ExpressionEngine.evaluate(dv as any, { now, + timezone: execCtx?.timezone, user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined, org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined, record: out,