@objectstack/formula 9.7.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/formula@9.7.0 build /home/runner/work/framework/framework/packages/formula
2
+ > @objectstack/formula@9.9.0 build /home/runner/work/framework/framework/packages/formula
3
3
  > tsup --config ../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- CJS dist/index.js 27.85 KB
14
- CJS dist/index.js.map 73.20 KB
15
- CJS ⚡️ Build success in 103ms
16
- ESM dist/index.mjs 26.12 KB
17
- ESM dist/index.mjs.map 71.83 KB
18
- ESM ⚡️ Build success in 104ms
13
+ CJS dist/index.js 31.13 KB
14
+ CJS dist/index.js.map 83.10 KB
15
+ CJS ⚡️ Build success in 67ms
16
+ ESM dist/index.mjs 29.37 KB
17
+ ESM dist/index.mjs.map 81.72 KB
18
+ ESM ⚡️ Build success in 84ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 5303ms
21
- DTS dist/index.d.mts 15.44 KB
22
- DTS dist/index.d.ts 15.44 KB
20
+ DTS ⚡️ Build success in 4303ms
21
+ DTS dist/index.d.mts 16.79 KB
22
+ DTS dist/index.d.ts 16.79 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # @objectstack/formula
2
2
 
3
+ ## 9.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d99a75a: feat(formula): timezone-aware `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2)
8
+
9
+ 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).
10
+
11
+ **⚠️ 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")`.
12
+
13
+ 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`).
14
+
15
+ Part of #1980. (Consuming a non-UTC reference timezone end-to-end also needs the `localization` settings manifest noted in #1978.)
16
+
17
+ - 575448d: feat(formula,email): render `datetime` in a reference timezone (ADR-0053 Phase 2)
18
+
19
+ `datetime` template holes now render in a reference timezone's wall-clock when one is supplied, at the presentation boundary — storage stays UTC.
20
+
21
+ - **Formula template engine** — the `datetime` formatter takes the reference timezone from `EvalContext.timezone` (threaded in #1980) and passes it to `Intl.DateTimeFormat`. `{{ ts | datetime }}` renders in that zone; `{{ ts | datetime:iso }}` stays UTC (machine-readable). Calendar-day `date` rendering is intentionally **unchanged** (tz-naive — a `Field.date` has no zone). New exported `formatValue(name, value, arg, { locale, timeZone })` makes the whitelisted formatters reusable outside the full CEL template engine.
22
+ - **Email pipeline** — `plugin-email`'s renderer previously bypassed the formatter pipeline (`String()` only), so a datetime went out as raw ISO. Email holes now accept the shared formula formatters — `{{ order.total | currency }}`, `{{ ts | datetime }}` — reusing `formatValue` (single source of truth), while keeping the engine's HTML-escaping and `{{{ }}}` raw-output semantics. `SendTemplateInput.timezone` (mirroring the existing `locale`) flows into rendering so an email's datetime shows the recipient's wall-clock.
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [84249a4]
27
+ - Updated dependencies [11af299]
28
+ - Updated dependencies [d5774b5]
29
+ - Updated dependencies [134043a]
30
+ - Updated dependencies [90108e0]
31
+ - Updated dependencies [9afeb2d]
32
+ - Updated dependencies [6bec07e]
33
+ - Updated dependencies [601cc11]
34
+ - Updated dependencies [575448d]
35
+ - @objectstack/spec@9.9.0
36
+
37
+ ## 9.8.0
38
+
39
+ ### Minor Changes
40
+
41
+ - c17d2c8: feat(formula): register the CEL functions the authoring catalog advertises (daysBetween, abs, round, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty, date, datetime)
42
+
43
+ `introspectScope` / `CEL_STDLIB_FUNCTIONS` advertised 25 functions to authors
44
+ (incl. AI), but only 8 were registered — 14 faulted at runtime (`daysBetween`,
45
+ `abs`, `round`, `min`, `max`, `upper`, `lower`, `len`, `isEmpty`, `contains`,
46
+ `startsWith`, `endsWith`, `matches`, plus `date`/`datetime`). Authors were told
47
+ to call functions that don't exist (e.g. `daysBetween` for "days remaining").
48
+
49
+ Register the genuinely-useful set in `registerStdLib` with dyn-lenient signatures
50
+ (so a `Field.date` arriving as a string still works) and internal coercion, and
51
+ reconcile the catalog so every advertised entry resolves — guarded by a test that
52
+ evaluates every `CEL_STDLIB_FUNCTIONS` entry. Pure additions; no behavior change
53
+ to existing expressions.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [97c55b3]
58
+ - Updated dependencies [1b1f490]
59
+ - @objectstack/spec@9.8.0
60
+
3
61
  ## 9.7.0
4
62
 
5
63
  ### Minor Changes
package/dist/index.d.mts CHANGED
@@ -24,6 +24,13 @@ import { Environment } from '@marcbachmann/cel-js';
24
24
  interface EvalContext {
25
25
  /** Logical "now" snapshot — pinned per evaluation run for determinism. */
26
26
  now?: Date;
27
+ /**
28
+ * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day
29
+ * functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering
30
+ * `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2).
31
+ * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
32
+ */
33
+ timezone?: string;
27
34
  /** Current authenticated subject (hook / action / view contexts). */
28
35
  user?: {
29
36
  id: string;
@@ -198,6 +205,20 @@ declare const cronEngine: DialectEngine;
198
205
 
199
206
  /** Public list of whitelisted template formatters (for introspection/docs). */
200
207
  declare const TEMPLATE_FORMATTERS: string[];
208
+ /**
209
+ * Apply a whitelisted formatter to a value, the single source of truth for
210
+ * value→string semantics across dialects. Returns `undefined` for an unknown
211
+ * formatter name so callers can decide how to handle it (the template engine
212
+ * rejects at compile time; other consumers may pass the raw value through).
213
+ *
214
+ * Exported so renderers that don't run the full CEL template engine — notably
215
+ * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc.
216
+ * identically to in-app templates, including reference-timezone `datetime`.
217
+ */
218
+ declare function formatValue(name: string, value: unknown, arg: string | undefined, opts?: {
219
+ locale?: string;
220
+ timeZone?: string;
221
+ }): string | undefined;
201
222
  declare const templateEngine: DialectEngine;
202
223
 
203
224
  /**
@@ -221,7 +242,7 @@ declare const templateEngine: DialectEngine;
221
242
  * and dependency-free — they're the contract surface for AI authors and must
222
243
  * stay legible.
223
244
  */
224
- declare function registerStdLib(env: Environment, now: () => Date): Environment;
245
+ declare function registerStdLib(env: Environment, now: () => Date, timezone?: string): Environment;
225
246
  /**
226
247
  * Build the variable scope for a single evaluation. Absent fields are simply
227
248
  * not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
@@ -364,7 +385,12 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
364
385
  roots: string[];
365
386
  functions: string[];
366
387
  };
367
- /** Public catalog of CEL stdlib functions available in expressions. */
388
+ /**
389
+ * Public catalog of CEL functions available in expressions — what `introspectScope`
390
+ * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
391
+ * either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
392
+ * list ahead of the runtime tells the author to call functions that fault (#1928).
393
+ */
368
394
  declare const CEL_STDLIB_FUNCTIONS: string[];
369
395
 
370
- export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
396
+ export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.d.ts CHANGED
@@ -24,6 +24,13 @@ import { Environment } from '@marcbachmann/cel-js';
24
24
  interface EvalContext {
25
25
  /** Logical "now" snapshot — pinned per evaluation run for determinism. */
26
26
  now?: Date;
27
+ /**
28
+ * Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day
29
+ * functions `today()` / `daysFromNow()` / `daysAgo()` and for rendering
30
+ * `datetime` template holes in that zone's wall-clock (ADR-0053 Phase 2).
31
+ * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
32
+ */
33
+ timezone?: string;
27
34
  /** Current authenticated subject (hook / action / view contexts). */
28
35
  user?: {
29
36
  id: string;
@@ -198,6 +205,20 @@ declare const cronEngine: DialectEngine;
198
205
 
199
206
  /** Public list of whitelisted template formatters (for introspection/docs). */
200
207
  declare const TEMPLATE_FORMATTERS: string[];
208
+ /**
209
+ * Apply a whitelisted formatter to a value, the single source of truth for
210
+ * value→string semantics across dialects. Returns `undefined` for an unknown
211
+ * formatter name so callers can decide how to handle it (the template engine
212
+ * rejects at compile time; other consumers may pass the raw value through).
213
+ *
214
+ * Exported so renderers that don't run the full CEL template engine — notably
215
+ * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc.
216
+ * identically to in-app templates, including reference-timezone `datetime`.
217
+ */
218
+ declare function formatValue(name: string, value: unknown, arg: string | undefined, opts?: {
219
+ locale?: string;
220
+ timeZone?: string;
221
+ }): string | undefined;
201
222
  declare const templateEngine: DialectEngine;
202
223
 
203
224
  /**
@@ -221,7 +242,7 @@ declare const templateEngine: DialectEngine;
221
242
  * and dependency-free — they're the contract surface for AI authors and must
222
243
  * stay legible.
223
244
  */
224
- declare function registerStdLib(env: Environment, now: () => Date): Environment;
245
+ declare function registerStdLib(env: Environment, now: () => Date, timezone?: string): Environment;
225
246
  /**
226
247
  * Build the variable scope for a single evaluation. Absent fields are simply
227
248
  * not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
@@ -364,7 +385,12 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
364
385
  roots: string[];
365
386
  functions: string[];
366
387
  };
367
- /** Public catalog of CEL stdlib functions available in expressions. */
388
+ /**
389
+ * Public catalog of CEL functions available in expressions — what `introspectScope`
390
+ * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
391
+ * either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
392
+ * list ahead of the runtime tells the author to call functions that fault (#1928).
393
+ */
368
394
  declare const CEL_STDLIB_FUNCTIONS: string[];
369
395
 
370
- export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
396
+ export { CEL_STDLIB_FUNCTIONS, DEFAULT_LIMITS, type DialectEngine, type EvalContext, type EvalError, type EvalResult, type ExprSchemaHint, type ExprValidationError, type ExprValidationResult, ExpressionEngine, type FieldRole, type SeedPrimitive, type SeedValue, TEMPLATE_FORMATTERS, buildScope, celEngine, cronEngine, expectedDialect, formatValue, getEngine, hasDialect, introspectScope, normalizeExpression, normalizeExpressionTree, register, registerStdLib, resolveSeed, resolveSeedRecord, templateEngine, validateExpression };
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  celEngine: () => celEngine,
29
29
  cronEngine: () => cronEngine,
30
30
  expectedDialect: () => expectedDialect,
31
+ formatValue: () => formatValue,
31
32
  getEngine: () => getEngine,
32
33
  hasDialect: () => hasDialect,
33
34
  introspectScope: () => introspectScope,
@@ -46,26 +47,52 @@ module.exports = __toCommonJS(index_exports);
46
47
  var import_cel_js = require("@marcbachmann/cel-js");
47
48
 
48
49
  // src/stdlib.ts
50
+ function partsInTz(d, tz) {
51
+ const parts = new Intl.DateTimeFormat("en-US", {
52
+ timeZone: tz,
53
+ year: "numeric",
54
+ month: "2-digit",
55
+ day: "2-digit"
56
+ }).formatToParts(d);
57
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
58
+ return { y: get("year"), m: get("month"), day: get("day") };
59
+ }
60
+ function calendarDayUtc(d, tz) {
61
+ if (tz && tz !== "UTC") {
62
+ try {
63
+ const { y, m, day } = partsInTz(d, tz);
64
+ return new Date(Date.UTC(y, m - 1, day));
65
+ } catch {
66
+ }
67
+ }
68
+ return startOfDayUtc(d);
69
+ }
49
70
  function startOfDayUtc(d) {
50
71
  const out = new Date(d.getTime());
51
72
  out.setUTCHours(0, 0, 0, 0);
52
73
  return out;
53
74
  }
75
+ function toDate(v) {
76
+ if (v instanceof Date) return v;
77
+ if (typeof v === "number" || typeof v === "bigint") return new Date(Number(v));
78
+ return new Date(String(v));
79
+ }
80
+ var MS_PER_DAY = 864e5;
54
81
  function addDaysUtc(d, n) {
55
82
  const out = new Date(d.getTime());
56
83
  out.setUTCDate(out.getUTCDate() + n);
57
84
  return out;
58
85
  }
59
- function registerStdLib(env, now) {
86
+ function registerStdLib(env, now, timezone = "UTC") {
60
87
  return env.registerFunction("now(): google.protobuf.Timestamp", () => now()).registerFunction(
61
88
  "today(): google.protobuf.Timestamp",
62
- () => startOfDayUtc(now())
89
+ () => calendarDayUtc(now(), timezone)
63
90
  ).registerFunction(
64
91
  "daysFromNow(int): google.protobuf.Timestamp",
65
- (n) => addDaysUtc(now(), Number(n))
92
+ (n) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n))
66
93
  ).registerFunction(
67
94
  "daysAgo(int): google.protobuf.Timestamp",
68
- (n) => addDaysUtc(now(), -Number(n))
95
+ (n) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n))
69
96
  ).registerFunction(
70
97
  "isBlank(dyn): bool",
71
98
  (value) => {
@@ -96,8 +123,20 @@ function registerStdLib(env, now) {
96
123
  }
97
124
  return parts.join(separator);
98
125
  }
126
+ ).registerFunction(
127
+ "daysBetween(dyn, dyn): int",
128
+ (a, b) => BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY))
129
+ ).registerFunction("date(dyn): google.protobuf.Timestamp", (s) => toDate(s)).registerFunction("datetime(dyn): google.protobuf.Timestamp", (s) => toDate(s)).registerFunction("abs(dyn): double", (x) => Math.abs(Number(x))).registerFunction("round(dyn): int", (x) => BigInt(Math.round(Number(x)))).registerFunction("min(dyn, dyn): dyn", (a, b) => Number(a) <= Number(b) ? a : b).registerFunction("max(dyn, dyn): dyn", (a, b) => Number(a) >= Number(b) ? a : b).registerFunction("upper(dyn): string", (s) => String(s ?? "").toUpperCase()).registerFunction("lower(dyn): string", (s) => String(s ?? "").toLowerCase()).registerFunction("contains(dyn, dyn): bool", (s, sub) => String(s ?? "").includes(String(sub ?? ""))).registerFunction("startsWith(dyn, dyn): bool", (s, p) => String(s ?? "").startsWith(String(p ?? ""))).registerFunction("endsWith(dyn, dyn): bool", (s, p) => String(s ?? "").endsWith(String(p ?? ""))).registerFunction("matches(dyn, dyn): bool", (s, re) => new RegExp(String(re ?? "")).test(String(s ?? ""))).registerFunction("len(dyn): int", (v) => BigInt(lengthOf(v))).registerFunction(
130
+ "isEmpty(dyn): bool",
131
+ (v) => v === null || v === void 0 || lengthOf(v) === 0
99
132
  );
100
133
  }
134
+ function lengthOf(v) {
135
+ if (v === null || v === void 0) return 0;
136
+ if (typeof v === "string" || Array.isArray(v)) return v.length;
137
+ if (typeof v === "object") return Object.keys(v).length;
138
+ return 0;
139
+ }
101
140
  function registerNumericCoercions(env) {
102
141
  const ops = {
103
142
  "+": (a, b) => a + b,
@@ -135,13 +174,13 @@ var DEFAULT_LIMITS = {
135
174
  maxMapEntries: 64,
136
175
  maxCallArguments: 16
137
176
  };
138
- function buildEnv(now) {
177
+ function buildEnv(now, timezone = "UTC") {
139
178
  const env = new import_cel_js.Environment({
140
179
  unlistedVariablesAreDyn: true,
141
180
  enableOptionalTypes: true,
142
181
  limits: DEFAULT_LIMITS
143
182
  });
144
- return registerNumericCoercions(registerStdLib(env, now));
183
+ return registerNumericCoercions(registerStdLib(env, now, timezone));
145
184
  }
146
185
  var SCOPE_ROOTS = [
147
186
  "record",
@@ -287,7 +326,7 @@ var celEngine = {
287
326
  }
288
327
  const now = () => ctx.now ?? /* @__PURE__ */ new Date();
289
328
  try {
290
- const env = buildEnv(now);
329
+ const env = buildEnv(now, ctx.timezone ?? "UTC");
291
330
  const scope = buildScope(ctx);
292
331
  try {
293
332
  const raw = env.evaluate(source, scope);
@@ -427,7 +466,9 @@ var FORMATTERS = {
427
466
  maximumFractionDigits: Number.isNaN(digits) ? 0 : digits
428
467
  }).format(n);
429
468
  },
430
- // date | date:long | date:iso → date-only
469
+ // date | date:long | date:iso → date-only. Intentionally tz-naive
470
+ // (ADR-0053): a `Field.date` is a calendar day with no zone, so rendering
471
+ // never applies a reference timezone — that would shift the day.
431
472
  date: (v, arg, locale) => {
432
473
  const d = asDate(v);
433
474
  if (!d) return baseString(v);
@@ -435,15 +476,18 @@ var FORMATTERS = {
435
476
  const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
436
477
  return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(d);
437
478
  },
438
- // datetime | datetime:long | datetime:iso
439
- datetime: (v, arg, locale) => {
479
+ // datetime | datetime:long | datetime:iso. A `datetime` is a UTC instant;
480
+ // when a reference `timeZone` is supplied (ADR-0053 Phase 2) the wall-clock
481
+ // styles render in that zone. `iso` stays UTC (machine-readable, unambiguous).
482
+ datetime: (v, arg, locale, timeZone) => {
440
483
  const d = asDate(v);
441
484
  if (!d) return baseString(v);
442
485
  if (arg === "iso") return d.toISOString();
443
486
  const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
444
487
  return new Intl.DateTimeFormat(locale, {
445
488
  dateStyle: style,
446
- timeStyle: style
489
+ timeStyle: style,
490
+ ...timeZone ? { timeZone } : {}
447
491
  }).format(d);
448
492
  },
449
493
  // truncate:80 → cut with an ellipsis
@@ -467,6 +511,11 @@ var FORMATTERS = {
467
511
  }
468
512
  };
469
513
  var TEMPLATE_FORMATTERS = Object.keys(FORMATTERS);
514
+ function formatValue(name, value, arg, opts = {}) {
515
+ const fmt = FORMATTERS[name];
516
+ if (!fmt) return void 0;
517
+ return fmt(value, arg, opts.locale ?? "en-US", opts.timeZone);
518
+ }
470
519
  function baseString(value) {
471
520
  if (value === null || value === void 0) return "";
472
521
  if (value instanceof Date) return value.toISOString();
@@ -552,12 +601,13 @@ var templateEngine = {
552
601
  if (!check.ok) return check;
553
602
  const scope = buildScope(ctx);
554
603
  const locale = ctx.extra && typeof ctx.extra.locale === "string" && ctx.extra.locale || typeof ctx.locale === "string" && ctx.locale || "en-US";
604
+ const timeZone = typeof ctx.timezone === "string" ? ctx.timezone : void 0;
555
605
  const out = expr.source.replace(HOLE_RE, (_match, inner) => {
556
606
  const parsed = parseHole(String(inner));
557
607
  if (!parsed) return _match;
558
608
  const value = resolvePath(scope, parsed.path);
559
609
  if (parsed.filter) {
560
- return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale);
610
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone);
561
611
  }
562
612
  return baseString(value);
563
613
  });
@@ -854,22 +904,20 @@ function introspectScope(role, schema) {
854
904
  };
855
905
  }
856
906
  var CEL_STDLIB_FUNCTIONS = [
907
+ // Dates (registered stdlib)
857
908
  "now",
858
909
  "today",
859
910
  "daysFromNow",
911
+ "daysAgo",
860
912
  "daysBetween",
861
913
  "date",
862
914
  "datetime",
863
- "timestamp",
864
- "isBlank",
865
- "isEmpty",
866
- "coalesce",
867
- "len",
868
- "size",
869
- "int",
870
- "float",
871
- "string",
872
- "bool",
915
+ // Numbers (registered stdlib)
916
+ "abs",
917
+ "round",
918
+ "min",
919
+ "max",
920
+ // Strings (registered stdlib)
873
921
  "upper",
874
922
  "lower",
875
923
  "trim",
@@ -877,11 +925,21 @@ var CEL_STDLIB_FUNCTIONS = [
877
925
  "startsWith",
878
926
  "endsWith",
879
927
  "matches",
928
+ "joinNonEmpty",
929
+ // Collections / null-ish (registered stdlib)
930
+ "isBlank",
931
+ "isEmpty",
932
+ "coalesce",
933
+ "len",
934
+ // cel-js built-ins (verified to resolve)
935
+ "size",
880
936
  "has",
881
- "min",
882
- "max",
883
- "abs",
884
- "round"
937
+ "int",
938
+ "string",
939
+ "bool",
940
+ "double",
941
+ "timestamp",
942
+ "duration"
885
943
  ];
886
944
  // Annotate the CommonJS export names for ESM import in node:
887
945
  0 && (module.exports = {
@@ -893,6 +951,7 @@ var CEL_STDLIB_FUNCTIONS = [
893
951
  celEngine,
894
952
  cronEngine,
895
953
  expectedDialect,
954
+ formatValue,
896
955
  getEngine,
897
956
  hasDialect,
898
957
  introspectScope,