Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/tz-aware-time-functions.md
Original file line number Diff line number Diff line change
@@ -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.)
21 changes: 19 additions & 2 deletions packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions packages/formula/src/seed-eval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
45 changes: 42 additions & 3 deletions packages/formula/src/stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/formula/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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,
Expand Down
Loading