@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +58 -0
- package/dist/index.d.mts +29 -3
- package/dist/index.d.ts +29 -3
- package/dist/index.js +85 -26
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +84 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.test.ts +93 -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 +101 -3
- package/src/template-engine.ts +39 -5
- package/src/template-formatters.test.ts +36 -3
- package/src/types.ts +7 -0
- package/src/validate.ts +16 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { CEL_STDLIB_FUNCTIONS } from './validate';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Drift-guard (#1928 follow-up): the objectstack-formula authoring skill is what
|
|
10
|
+
* the AI author reads to know which CEL functions exist. It MUST stay in sync
|
|
11
|
+
* with the runtime catalog — a function advertised in the catalog but missing
|
|
12
|
+
* from the skill means the AI never reaches for it; one in the skill but not the
|
|
13
|
+
* catalog means the AI calls a function that faults the build. This pins the
|
|
14
|
+
* skill ↔ `CEL_STDLIB_FUNCTIONS` mapping so neither drifts silently again
|
|
15
|
+
* (mirrors the runtime drift-guard in cel-engine.test.ts).
|
|
16
|
+
*/
|
|
17
|
+
describe('objectstack-formula skill ↔ CEL_STDLIB_FUNCTIONS', () => {
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const skillPath = resolve(here, '../../../skills/objectstack-formula/SKILL.md');
|
|
20
|
+
const skill = readFileSync(skillPath, 'utf8');
|
|
21
|
+
|
|
22
|
+
it('documents every advertised stdlib function', () => {
|
|
23
|
+
// A function is "documented" if its name appears as `` `name(` `` (a call
|
|
24
|
+
// form) anywhere in the skill — robust to table layout / grouping changes.
|
|
25
|
+
const missing = CEL_STDLIB_FUNCTIONS.filter((fn) => !skill.includes(`\`${fn}(`));
|
|
26
|
+
expect(
|
|
27
|
+
missing,
|
|
28
|
+
`These CEL_STDLIB_FUNCTIONS are not documented in skills/objectstack-formula/SKILL.md:\n` +
|
|
29
|
+
`${missing.join(', ')}\nAdd them to the stdlib table so AI authors know they exist.`,
|
|
30
|
+
).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
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());
|
|
@@ -22,6 +56,16 @@ function startOfDayUtc(d: Date): Date {
|
|
|
22
56
|
return out;
|
|
23
57
|
}
|
|
24
58
|
|
|
59
|
+
/** Coerce a CEL value (Date | ISO string | epoch number) to a Date. */
|
|
60
|
+
function toDate(v: unknown): Date {
|
|
61
|
+
if (v instanceof Date) return v;
|
|
62
|
+
if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v));
|
|
63
|
+
return new Date(String(v));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One UTC day in milliseconds. */
|
|
67
|
+
const MS_PER_DAY = 86_400_000;
|
|
68
|
+
|
|
25
69
|
/** Add `n` days to a Date in UTC; returns a new Date. */
|
|
26
70
|
function addDaysUtc(d: Date, n: number): Date {
|
|
27
71
|
const out = new Date(d.getTime());
|
|
@@ -40,20 +84,25 @@ function addDaysUtc(d: Date, n: number): Date {
|
|
|
40
84
|
export function registerStdLib(
|
|
41
85
|
env: Environment,
|
|
42
86
|
now: () => Date,
|
|
87
|
+
timezone = 'UTC',
|
|
43
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")`.
|
|
44
93
|
return env
|
|
45
94
|
.registerFunction('now(): google.protobuf.Timestamp', () => now())
|
|
46
95
|
.registerFunction(
|
|
47
96
|
'today(): google.protobuf.Timestamp',
|
|
48
|
-
() =>
|
|
97
|
+
() => calendarDayUtc(now(), timezone),
|
|
49
98
|
)
|
|
50
99
|
.registerFunction(
|
|
51
100
|
'daysFromNow(int): google.protobuf.Timestamp',
|
|
52
|
-
(n: bigint | number) => addDaysUtc(now(), Number(n)),
|
|
101
|
+
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)),
|
|
53
102
|
)
|
|
54
103
|
.registerFunction(
|
|
55
104
|
'daysAgo(int): google.protobuf.Timestamp',
|
|
56
|
-
(n: bigint | number) => addDaysUtc(now(), -Number(n)),
|
|
105
|
+
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)),
|
|
57
106
|
)
|
|
58
107
|
// Returns true when `value` is null, undefined, empty string, or empty list.
|
|
59
108
|
// Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic.
|
|
@@ -101,9 +150,58 @@ export function registerStdLib(
|
|
|
101
150
|
}
|
|
102
151
|
return parts.join(separator);
|
|
103
152
|
},
|
|
153
|
+
)
|
|
154
|
+
// ── Dates ────────────────────────────────────────────────────────────
|
|
155
|
+
// Whole days from `a` to `b` (negative if `b` is before `a`). The common
|
|
156
|
+
// shape is `daysBetween(today(), record.due)` for "days remaining". Args are
|
|
157
|
+
// coerced (Date | ISO string | epoch) so a `Field.date` that arrives as a
|
|
158
|
+
// string still works without the caller hydrating it.
|
|
159
|
+
.registerFunction(
|
|
160
|
+
'daysBetween(dyn, dyn): int',
|
|
161
|
+
(a: unknown, b: unknown) =>
|
|
162
|
+
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
|
|
163
|
+
)
|
|
164
|
+
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
|
|
165
|
+
// are aliases — both accept either form (the field's own type decides the
|
|
166
|
+
// intent); kept distinct because authors reach for whichever reads clearer.
|
|
167
|
+
.registerFunction('date(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
|
|
168
|
+
.registerFunction('datetime(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
|
|
169
|
+
// ── Numbers ──────────────────────────────────────────────────────────
|
|
170
|
+
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
|
|
171
|
+
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))
|
|
172
|
+
// min/max return the smaller/larger operand verbatim (type preserved) rather
|
|
173
|
+
// than a coerced copy, so `min(record.a, record.b)` keeps int-ness when both
|
|
174
|
+
// are ints. Comparison is numeric.
|
|
175
|
+
.registerFunction('min(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) <= Number(b) ? a : b))
|
|
176
|
+
.registerFunction('max(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) >= Number(b) ? a : b))
|
|
177
|
+
// ── Strings ──────────────────────────────────────────────────────────
|
|
178
|
+
// Free-function forms of the common string ops. CEL also exposes some as
|
|
179
|
+
// receiver methods (`s.contains(x)`), but the authoring catalog advertises
|
|
180
|
+
// the bare-call form, so register it to match what authors are told to use.
|
|
181
|
+
.registerFunction('upper(dyn): string', (s: unknown) => String(s ?? '').toUpperCase())
|
|
182
|
+
.registerFunction('lower(dyn): string', (s: unknown) => String(s ?? '').toLowerCase())
|
|
183
|
+
.registerFunction('contains(dyn, dyn): bool', (s: unknown, sub: unknown) => String(s ?? '').includes(String(sub ?? '')))
|
|
184
|
+
.registerFunction('startsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').startsWith(String(p ?? '')))
|
|
185
|
+
.registerFunction('endsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').endsWith(String(p ?? '')))
|
|
186
|
+
.registerFunction('matches(dyn, dyn): bool', (s: unknown, re: unknown) => new RegExp(String(re ?? '')).test(String(s ?? '')))
|
|
187
|
+
// ── Collections ──────────────────────────────────────────────────────
|
|
188
|
+
// `len` mirrors CEL's built-in `size()` for strings/lists/maps; `isEmpty` is
|
|
189
|
+
// the inverse-of-non-empty companion to `isBlank` (true for null, '', []).
|
|
190
|
+
.registerFunction('len(dyn): int', (v: unknown) => BigInt(lengthOf(v)))
|
|
191
|
+
.registerFunction(
|
|
192
|
+
'isEmpty(dyn): bool',
|
|
193
|
+
(v: unknown) => v === null || v === undefined || lengthOf(v) === 0,
|
|
104
194
|
);
|
|
105
195
|
}
|
|
106
196
|
|
|
197
|
+
/** Length of a string / list / map (0 for scalars and null). */
|
|
198
|
+
function lengthOf(v: unknown): number {
|
|
199
|
+
if (v === null || v === undefined) return 0;
|
|
200
|
+
if (typeof v === 'string' || Array.isArray(v)) return v.length;
|
|
201
|
+
if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length;
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
107
205
|
/**
|
|
108
206
|
* Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
|
|
109
207
|
*
|
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;
|
package/src/validate.ts
CHANGED
|
@@ -256,10 +256,21 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
|
|
|
256
256
|
};
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Public catalog of CEL functions available in expressions — what `introspectScope`
|
|
261
|
+
* advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
|
|
262
|
+
* either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
|
|
263
|
+
* list ahead of the runtime tells the author to call functions that fault (#1928).
|
|
264
|
+
*/
|
|
260
265
|
export const CEL_STDLIB_FUNCTIONS: string[] = [
|
|
261
|
-
|
|
262
|
-
'
|
|
263
|
-
|
|
264
|
-
'
|
|
266
|
+
// Dates (registered stdlib)
|
|
267
|
+
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime',
|
|
268
|
+
// Numbers (registered stdlib)
|
|
269
|
+
'abs', 'round', 'min', 'max',
|
|
270
|
+
// Strings (registered stdlib)
|
|
271
|
+
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches', 'joinNonEmpty',
|
|
272
|
+
// Collections / null-ish (registered stdlib)
|
|
273
|
+
'isBlank', 'isEmpty', 'coalesce', 'len',
|
|
274
|
+
// cel-js built-ins (verified to resolve)
|
|
275
|
+
'size', 'has', 'int', 'string', 'bool', 'double', 'timestamp', 'duration',
|
|
265
276
|
];
|