@objectstack/formula 9.8.0 → 9.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stdlib.ts CHANGED
@@ -15,6 +15,40 @@ import type { Environment } from '@marcbachmann/cel-js';
15
15
 
16
16
  import type { EvalContext } from './types';
17
17
 
18
+ /**
19
+ * Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
20
+ * (ADR-0053 Phase 2). Uses `Intl.DateTimeFormat` so DST transitions are
21
+ * handled correctly — never hand-rolled offset math. An unknown zone throws,
22
+ * which the caller treats as a fall-through to UTC.
23
+ */
24
+ function partsInTz(d: Date, tz: string): { y: number; m: number; day: number } {
25
+ const parts = new Intl.DateTimeFormat('en-US', {
26
+ timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
27
+ }).formatToParts(d);
28
+ const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
29
+ return { y: get('year'), m: get('month'), day: get('day') };
30
+ }
31
+
32
+ /**
33
+ * The calendar day of an instant *in a reference timezone*, expressed as a
34
+ * UTC-midnight `Date` (ADR-0053 Phase 2, decision D1). This is the one
35
+ * representation consistent with how `Field.date` strings hydrate (UTC
36
+ * midnight), how the SQL driver normalizes date filters, and how Phase 1
37
+ * stores dates — so `record.date == today()` compares cleanly. Falls back to
38
+ * the UTC calendar day for `UTC` or an invalid zone.
39
+ */
40
+ function calendarDayUtc(d: Date, tz: string): Date {
41
+ if (tz && tz !== 'UTC') {
42
+ try {
43
+ const { y, m, day } = partsInTz(d, tz);
44
+ return new Date(Date.UTC(y, m - 1, day));
45
+ } catch {
46
+ // unknown zone → fall through to UTC
47
+ }
48
+ }
49
+ return startOfDayUtc(d);
50
+ }
51
+
18
52
  /** Truncate a Date to start-of-day in UTC. */
19
53
  function startOfDayUtc(d: Date): Date {
20
54
  const out = new Date(d.getTime());
@@ -50,20 +84,25 @@ function addDaysUtc(d: Date, n: number): Date {
50
84
  export function registerStdLib(
51
85
  env: Environment,
52
86
  now: () => Date,
87
+ timezone = 'UTC',
53
88
  ): Environment {
89
+ // `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they
90
+ // resolve to the reference-tz calendar day expressed as a UTC-midnight Date
91
+ // (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a
92
+ // genuine sub-day offset use `now() + duration("Nh")`.
54
93
  return env
55
94
  .registerFunction('now(): google.protobuf.Timestamp', () => now())
56
95
  .registerFunction(
57
96
  'today(): google.protobuf.Timestamp',
58
- () => startOfDayUtc(now()),
97
+ () => calendarDayUtc(now(), timezone),
59
98
  )
60
99
  .registerFunction(
61
100
  'daysFromNow(int): google.protobuf.Timestamp',
62
- (n: bigint | number) => addDaysUtc(now(), Number(n)),
101
+ (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)),
63
102
  )
64
103
  .registerFunction(
65
104
  'daysAgo(int): google.protobuf.Timestamp',
66
- (n: bigint | number) => addDaysUtc(now(), -Number(n)),
105
+ (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)),
67
106
  )
68
107
  // Returns true when `value` is null, undefined, empty string, or empty list.
69
108
  // Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic.
@@ -32,7 +32,12 @@ const HOLE_RE = /\{\{([^}]*)\}\}/g;
32
32
 
33
33
  // ───────────────────────── formatter whitelist (ADR-0032 §3) ──────────────
34
34
 
35
- type Formatter = (value: unknown, arg: string | undefined, locale: string) => string;
35
+ type Formatter = (
36
+ value: unknown,
37
+ arg: string | undefined,
38
+ locale: string,
39
+ timeZone?: string,
40
+ ) => string;
36
41
 
37
42
  function asNumber(v: unknown): number | undefined {
38
43
  if (typeof v === 'number') return v;
@@ -85,7 +90,9 @@ const FORMATTERS: Record<string, Formatter> = {
85
90
  maximumFractionDigits: Number.isNaN(digits) ? 0 : digits,
86
91
  }).format(n);
87
92
  },
88
- // date | date:long | date:iso → date-only
93
+ // date | date:long | date:iso → date-only. Intentionally tz-naive
94
+ // (ADR-0053): a `Field.date` is a calendar day with no zone, so rendering
95
+ // never applies a reference timezone — that would shift the day.
89
96
  date: (v, arg, locale) => {
90
97
  const d = asDate(v);
91
98
  if (!d) return baseString(v);
@@ -93,8 +100,10 @@ const FORMATTERS: Record<string, Formatter> = {
93
100
  const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
94
101
  return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long' }).format(d);
95
102
  },
96
- // datetime | datetime:long | datetime:iso
97
- datetime: (v, arg, locale) => {
103
+ // datetime | datetime:long | datetime:iso. A `datetime` is a UTC instant;
104
+ // when a reference `timeZone` is supplied (ADR-0053 Phase 2) the wall-clock
105
+ // styles render in that zone. `iso` stays UTC (machine-readable, unambiguous).
106
+ datetime: (v, arg, locale, timeZone) => {
98
107
  const d = asDate(v);
99
108
  if (!d) return baseString(v);
100
109
  if (arg === 'iso') return d.toISOString();
@@ -102,6 +111,7 @@ const FORMATTERS: Record<string, Formatter> = {
102
111
  return new Intl.DateTimeFormat(locale, {
103
112
  dateStyle: style as 'short' | 'medium' | 'long',
104
113
  timeStyle: style as 'short' | 'medium' | 'long',
114
+ ...(timeZone ? { timeZone } : {}),
105
115
  }).format(d);
106
116
  },
107
117
  // truncate:80 → cut with an ellipsis
@@ -124,6 +134,27 @@ const FORMATTERS: Record<string, Formatter> = {
124
134
  /** Public list of whitelisted template formatters (for introspection/docs). */
125
135
  export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
126
136
 
137
+ /**
138
+ * Apply a whitelisted formatter to a value, the single source of truth for
139
+ * value→string semantics across dialects. Returns `undefined` for an unknown
140
+ * formatter name so callers can decide how to handle it (the template engine
141
+ * rejects at compile time; other consumers may pass the raw value through).
142
+ *
143
+ * Exported so renderers that don't run the full CEL template engine — notably
144
+ * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc.
145
+ * identically to in-app templates, including reference-timezone `datetime`.
146
+ */
147
+ export function formatValue(
148
+ name: string,
149
+ value: unknown,
150
+ arg: string | undefined,
151
+ opts: { locale?: string; timeZone?: string } = {},
152
+ ): string | undefined {
153
+ const fmt = FORMATTERS[name];
154
+ if (!fmt) return undefined;
155
+ return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone);
156
+ }
157
+
127
158
  function baseString(value: unknown): string {
128
159
  if (value === null || value === undefined) return '';
129
160
  if (value instanceof Date) return value.toISOString();
@@ -234,13 +265,16 @@ export const templateEngine: DialectEngine = {
234
265
  (ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
235
266
  (typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
236
267
  'en-US';
268
+ // Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset →
269
+ // Intl uses the runtime zone, matching pre-Phase-2 behavior.
270
+ const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined;
237
271
 
238
272
  const out = expr.source.replace(HOLE_RE, (_match, inner) => {
239
273
  const parsed = parseHole(String(inner));
240
274
  if (!parsed) return _match; // compile already validated; defensive
241
275
  const value = resolvePath(scope, parsed.path);
242
276
  if (parsed.filter) {
243
- return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string);
277
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone);
244
278
  }
245
279
  return baseString(value);
246
280
  });
@@ -1,8 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { templateEngine, TEMPLATE_FORMATTERS } from './template-engine';
2
+ import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-engine';
3
3
 
4
- function render(source: string, record: Record<string, unknown>): string {
5
- const r = templateEngine.evaluate<string>({ dialect: 'template', source }, { record, extra: { locale: 'en-US' } });
4
+ function render(source: string, record: Record<string, unknown>, timezone?: string): string {
5
+ const r = templateEngine.evaluate<string>(
6
+ { dialect: 'template', source },
7
+ { record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) },
8
+ );
6
9
  if (!r.ok) throw new Error(r.error.message);
7
10
  return r.value;
8
11
  }
@@ -30,6 +33,36 @@ describe('template formatters (ADR-0032 §3)', () => {
30
33
  expect(render('{{ record.d | date:iso }}', { d: '2026-06-02T10:00:00Z' })).toBe('2026-06-02');
31
34
  });
32
35
 
36
+ // ADR-0053 Phase 2: datetime renders in the reference timezone.
37
+ describe('datetime in a reference timezone', () => {
38
+ // 2026-06-02T01:30Z is 2026-06-01 21:30 in America/New_York (EDT, -4).
39
+ const ts = '2026-06-02T01:30:00Z';
40
+
41
+ it('renders the wall-clock of the reference zone for styled output', () => {
42
+ const ny = render('{{ record.t | datetime }}', { t: ts }, 'America/New_York');
43
+ expect(ny).toContain('6/1/26'); // shifted back a day in NY
44
+ const utc = render('{{ record.t | datetime }}', { t: ts }, 'UTC');
45
+ expect(utc).toContain('6/2/26'); // same instant, UTC calendar day
46
+ });
47
+
48
+ it('keeps `iso` machine-readable (always UTC) regardless of zone', () => {
49
+ expect(render('{{ record.t | datetime:iso }}', { t: ts }, 'America/New_York'))
50
+ .toBe('2026-06-02T01:30:00.000Z');
51
+ });
52
+
53
+ it('leaves calendar-day `date` tz-naive (criterion 3)', () => {
54
+ // date:iso is UTC-sliced and must not move under a negative-offset zone.
55
+ expect(render('{{ record.d | date:iso }}', { d: '2026-06-02' }, 'America/New_York'))
56
+ .toBe('2026-06-02');
57
+ });
58
+
59
+ it('formatValue is reusable with an explicit timeZone (email pipeline path)', () => {
60
+ const out = formatValue('datetime', ts, undefined, { locale: 'en-US', timeZone: 'UTC' });
61
+ expect(out).toContain('6/2/26');
62
+ expect(formatValue('bogus', ts, undefined, {})).toBeUndefined();
63
+ });
64
+ });
65
+
33
66
  it('truncate + upper + default', () => {
34
67
  expect(render('{{ record.s | truncate:5 }}', { s: 'abcdefgh' })).toBe('abcd…');
35
68
  expect(render('{{ record.s | upper }}', { s: 'hi' })).toBe('HI');
package/src/types.ts CHANGED
@@ -23,6 +23,13 @@ import type { Expression } from '@objectstack/spec';
23
23
  export interface EvalContext {
24
24
  /** Logical "now" snapshot — pinned per evaluation run for determinism. */
25
25
  now?: Date;
26
+ /**
27
+ * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day
28
+ * functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering
29
+ * `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2).
30
+ * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
31
+ */
32
+ timezone?: string;
26
33
  /** Current authenticated subject (hook / action / view contexts). */
27
34
  user?: {
28
35
  id: string;