@objectstack/formula 9.8.0 → 9.9.0
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +34 -0
- package/dist/index.d.mts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +45 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +44 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.test.ts +19 -2
- package/src/cel-engine.ts +3 -3
- package/src/index.ts +1 -1
- package/src/seed-eval.test.ts +6 -2
- package/src/skill-catalog-sync.test.ts +32 -0
- package/src/stdlib.ts +42 -3
- package/src/template-engine.ts +39 -5
- package/src/template-formatters.test.ts +36 -3
- package/src/types.ts +7 -0
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
|
-
() =>
|
|
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.
|
package/src/template-engine.ts
CHANGED
|
@@ -32,7 +32,12 @@ const HOLE_RE = /\{\{([^}]*)\}\}/g;
|
|
|
32
32
|
|
|
33
33
|
// ───────────────────────── formatter whitelist (ADR-0032 §3) ──────────────
|
|
34
34
|
|
|
35
|
-
type Formatter = (
|
|
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
|
-
|
|
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
|
|
5
|
-
const r = templateEngine.evaluate<string>(
|
|
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;
|