@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/formula@9.8.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 29.77 KB
14
- CJS dist/index.js.map 78.89 KB
15
- CJS ⚡️ Build success in 123ms
16
- ESM dist/index.mjs 28.04 KB
17
- ESM dist/index.mjs.map 77.53 KB
18
- ESM ⚡️ Build success in 124ms
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 4371ms
21
- DTS dist/index.d.mts 15.71 KB
22
- DTS dist/index.d.ts 15.71 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,39 @@
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
+
3
37
  ## 9.8.0
4
38
 
5
39
  ### 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.
@@ -372,4 +393,4 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
372
393
  */
373
394
  declare const CEL_STDLIB_FUNCTIONS: string[];
374
395
 
375
- 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.
@@ -372,4 +393,4 @@ declare function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
372
393
  */
373
394
  declare const CEL_STDLIB_FUNCTIONS: string[];
374
395
 
375
- 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,6 +47,26 @@ 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);
@@ -62,16 +83,16 @@ function addDaysUtc(d, n) {
62
83
  out.setUTCDate(out.getUTCDate() + n);
63
84
  return out;
64
85
  }
65
- function registerStdLib(env, now) {
86
+ function registerStdLib(env, now, timezone = "UTC") {
66
87
  return env.registerFunction("now(): google.protobuf.Timestamp", () => now()).registerFunction(
67
88
  "today(): google.protobuf.Timestamp",
68
- () => startOfDayUtc(now())
89
+ () => calendarDayUtc(now(), timezone)
69
90
  ).registerFunction(
70
91
  "daysFromNow(int): google.protobuf.Timestamp",
71
- (n) => addDaysUtc(now(), Number(n))
92
+ (n) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n))
72
93
  ).registerFunction(
73
94
  "daysAgo(int): google.protobuf.Timestamp",
74
- (n) => addDaysUtc(now(), -Number(n))
95
+ (n) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n))
75
96
  ).registerFunction(
76
97
  "isBlank(dyn): bool",
77
98
  (value) => {
@@ -153,13 +174,13 @@ var DEFAULT_LIMITS = {
153
174
  maxMapEntries: 64,
154
175
  maxCallArguments: 16
155
176
  };
156
- function buildEnv(now) {
177
+ function buildEnv(now, timezone = "UTC") {
157
178
  const env = new import_cel_js.Environment({
158
179
  unlistedVariablesAreDyn: true,
159
180
  enableOptionalTypes: true,
160
181
  limits: DEFAULT_LIMITS
161
182
  });
162
- return registerNumericCoercions(registerStdLib(env, now));
183
+ return registerNumericCoercions(registerStdLib(env, now, timezone));
163
184
  }
164
185
  var SCOPE_ROOTS = [
165
186
  "record",
@@ -305,7 +326,7 @@ var celEngine = {
305
326
  }
306
327
  const now = () => ctx.now ?? /* @__PURE__ */ new Date();
307
328
  try {
308
- const env = buildEnv(now);
329
+ const env = buildEnv(now, ctx.timezone ?? "UTC");
309
330
  const scope = buildScope(ctx);
310
331
  try {
311
332
  const raw = env.evaluate(source, scope);
@@ -445,7 +466,9 @@ var FORMATTERS = {
445
466
  maximumFractionDigits: Number.isNaN(digits) ? 0 : digits
446
467
  }).format(n);
447
468
  },
448
- // 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.
449
472
  date: (v, arg, locale) => {
450
473
  const d = asDate(v);
451
474
  if (!d) return baseString(v);
@@ -453,15 +476,18 @@ var FORMATTERS = {
453
476
  const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
454
477
  return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(d);
455
478
  },
456
- // datetime | datetime:long | datetime:iso
457
- 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) => {
458
483
  const d = asDate(v);
459
484
  if (!d) return baseString(v);
460
485
  if (arg === "iso") return d.toISOString();
461
486
  const style = arg === "long" ? "long" : arg === "medium" ? "medium" : "short";
462
487
  return new Intl.DateTimeFormat(locale, {
463
488
  dateStyle: style,
464
- timeStyle: style
489
+ timeStyle: style,
490
+ ...timeZone ? { timeZone } : {}
465
491
  }).format(d);
466
492
  },
467
493
  // truncate:80 → cut with an ellipsis
@@ -485,6 +511,11 @@ var FORMATTERS = {
485
511
  }
486
512
  };
487
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
+ }
488
519
  function baseString(value) {
489
520
  if (value === null || value === void 0) return "";
490
521
  if (value instanceof Date) return value.toISOString();
@@ -570,12 +601,13 @@ var templateEngine = {
570
601
  if (!check.ok) return check;
571
602
  const scope = buildScope(ctx);
572
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;
573
605
  const out = expr.source.replace(HOLE_RE, (_match, inner) => {
574
606
  const parsed = parseHole(String(inner));
575
607
  if (!parsed) return _match;
576
608
  const value = resolvePath(scope, parsed.path);
577
609
  if (parsed.filter) {
578
- return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale);
610
+ return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone);
579
611
  }
580
612
  return baseString(value);
581
613
  });
@@ -919,6 +951,7 @@ var CEL_STDLIB_FUNCTIONS = [
919
951
  celEngine,
920
952
  cronEngine,
921
953
  expectedDialect,
954
+ formatValue,
922
955
  getEngine,
923
956
  hasDialect,
924
957
  introspectScope,