Skip to content

Commit eebccf4

Browse files
committed
feat(formula): isoDate(t) / isoDatetime(t), the string form of a CEL timestamp
Two CEL stdlib functions render a timestamp as ISO text on the UTC calendar: isoDate(t) is YYYY-MM-DD and isoDatetime(t) is YYYY-MM-DDTHH:mm:ss.sssZ. These are the bytes the flow template dialect writes for {TODAY()} and {NOW()}, and isoDate(daysFromNow(n)) / isoDate(daysAgo(n)) are its {TODAY() +/- n}, measured over month, year, leap-day and DST-transition instants under six host zones. Named functions rather than a string(timestamp) overload: one overload answers one shape per type, so it cannot spell the date shape, and CEL defines string(timestamp) as RFC 3339 that drops a zero fraction, which a byte match with the template would contradict. string(timestamp) stays refused and is pinned. The parameter is a timestamp: text, a number or null is refused (at build when typed, at run otherwise), never coerced. Both names join CEL_STDLIB_FUNCTIONS, the formula skill's stdlib table and the formulas docs page. No write path changes: a timestamp envelope still evaluates to a Date. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28bff18 commit eebccf4

7 files changed

Lines changed: 267 additions & 9 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
feat(formula): `isoDate(t)` and `isoDatetime(t)`, the string form of a CEL timestamp
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** Two CEL stdlib functions that turn a timestamp into ISO text on the UTC calendar. `isoDate(t)` returns `YYYY-MM-DD` and `isoDatetime(t)` returns `YYYY-MM-DDTHH:mm:ss.sssZ`, always with three-digit milliseconds and a `Z`. Both are in `CEL_STDLIB_FUNCTIONS`, so `introspectScope` advertises them and the build check accepts them.
10+
- **The bytes the flow template dialect wrote.** `isoDate(today())` is the text `{TODAY()}` wrote, `isoDatetime(now())` is the text `{NOW()}` wrote, and `isoDate(daysFromNow(n))` / `isoDate(daysAgo(n))` are the text `{TODAY() + n}` / `{TODAY() - n}` wrote, in a flow value envelope. This holds at month, year, leap-day and DST-transition instants, whatever the host's zone.
11+
- **`isoDate(today())` is the reference-timezone day.** `today()` is that day at UTC midnight, and `isoDate` reads the UTC calendar. Under a non-UTC reference zone, `isoDate(now())` can be a different day: it is the UTC day of the instant.
12+
- **Only a timestamp is accepted.** Text, a number or `null` is refused, at build when the argument's type is known and at run otherwise. It is never coerced or rendered. For ISO text, parse it first: `isoDate(date(s))`. An invalid timestamp, or one outside 0001-01-01 to 9999-12-31, is refused at run.
13+
- **`string(timestamp)` is still refused.** CEL defines that conversion as RFC 3339 text that drops a zero fraction, which is not the template's `.000Z`, so each shape has one spelling.
14+
- **No write path changes.** An envelope that returns a timestamp (`today()`, `now()`, `daysFromNow(n)`, `addDays(…)`) still evaluates to a `Date` and writes what it wrote before.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ All functions are pure given a pinned `now`, which is what makes
178178
| `daysFromNow(n)` / `daysAgo(n)` | timestamp | `today() ± n` days (calendar-day, not wall-clock) |
179179
| `addDays(d, n)` / `addMonths(d, n)` | timestamp | Shift a *given* date; `addMonths` clamps to month end (Jan 31 + 1mo → Feb 28) |
180180
| `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string (aliases) |
181+
| `isoDate(t)` / `isoDatetime(t)` | string | A timestamp as ISO text on the UTC calendar: `YYYY-MM-DD` / `YYYY-MM-DDTHH:mm:ss.sssZ`, the bytes a flow template's `{TODAY()}` / `{NOW()}` wrote. Refuses text, numbers and `null`: write `isoDate(date(s))` for ISO text |
181182
| `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative when `b` is earlier) |
182183
| `isBlank(v)` | bool | True for `null`, `undefined`, `''`, `[]` |
183184
| `isEmpty(v)` | bool | True for `null` or zero-length string/list/map |

‎packages/formula/src/cel-engine.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,8 @@ describe('celEngine', () => {
502502
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
503503
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
504504
addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)',
505-
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
505+
datetime: 'datetime("2026-03-15T08:00:00Z")', isoDate: 'isoDate(today())',
506+
isoDatetime: 'isoDatetime(now())', abs: 'abs(-3.5)', round: 'round(2.6)',
506507
floor: 'floor(6.7)', ceil: 'ceil(6.1)',
507508
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
508509
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
3+
import { celEngine } from './cel-engine';
4+
import { inferExpressionType, validateExpression } from './validate';
5+
6+
/**
7+
* `isoDate(t)` / `isoDatetime(t)` — the string form of a CEL timestamp.
8+
*
9+
* ## What they must reproduce
10+
*
11+
* The flow template dialect's date macros (`service-automation`
12+
* `builtin/template.ts`, `resolveToken`) write `new Date().toISOString()` for
13+
* `{NOW()}`, its first ten characters for `{TODAY()}`, and shift the instant by
14+
* whole UTC days (`setUTCDate`) for `{TODAY() ± N}` / `{NOW() ± N}`. The
15+
* expected strings in {@link TEMPLATE_BYTES} are that function's own output,
16+
* measured through `interpolateString` at these instants under six host
17+
* zones. The host zone changed no byte of it.
18+
*
19+
* ## The scope these pins evaluate in
20+
*
21+
* A flow value envelope is evaluated with `AutomationEngine.celScope`, which
22+
* passes `{ extra, record }` and nothing else, so the engine runs on the UTC
23+
* calendar with the wall clock. Here `now` pins the clock to the row's instant
24+
* and nothing else differs.
25+
*/
26+
27+
const flowScope = (at: string) => ({ now: new Date(at), extra: {}, record: {} });
28+
29+
function value(source: string, at: string): unknown {
30+
const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(at));
31+
if (!r.ok) throw new Error(`${source} @ ${at}: ${r.error.kind}: ${r.error.message}`);
32+
return r.value;
33+
}
34+
35+
/** `interpolateString` output at each instant, per token (measured; see the header). */
36+
const TEMPLATE_BYTES = [
37+
{ at: '2026-10-08T17:55:06.123Z', today: '2026-10-08', now: '2026-10-08T17:55:06.123Z', plus3: '2026-10-11', minus1: '2026-10-07', nowPlus1: '2026-10-09T17:55:06.123Z' },
38+
{ at: '2026-10-08T00:00:00.000Z', today: '2026-10-08', now: '2026-10-08T00:00:00.000Z', plus3: '2026-10-11', minus1: '2026-10-07', nowPlus1: '2026-10-09T00:00:00.000Z' },
39+
{ at: '2026-01-31T23:59:59.999Z', today: '2026-01-31', now: '2026-01-31T23:59:59.999Z', plus3: '2026-02-03', minus1: '2026-01-30', nowPlus1: '2026-02-01T23:59:59.999Z' },
40+
{ at: '2026-02-28T12:00:00.000Z', today: '2026-02-28', now: '2026-02-28T12:00:00.000Z', plus3: '2026-03-03', minus1: '2026-02-27', nowPlus1: '2026-03-01T12:00:00.000Z' },
41+
{ at: '2028-02-28T12:00:00.000Z', today: '2028-02-28', now: '2028-02-28T12:00:00.000Z', plus3: '2028-03-02', minus1: '2028-02-27', nowPlus1: '2028-02-29T12:00:00.000Z' },
42+
{ at: '2026-12-31T23:30:00.000Z', today: '2026-12-31', now: '2026-12-31T23:30:00.000Z', plus3: '2027-01-03', minus1: '2026-12-30', nowPlus1: '2027-01-01T23:30:00.000Z' },
43+
{ at: '2026-03-08T07:30:00.000Z', today: '2026-03-08', now: '2026-03-08T07:30:00.000Z', plus3: '2026-03-11', minus1: '2026-03-07', nowPlus1: '2026-03-09T07:30:00.000Z' },
44+
{ at: '2026-03-29T00:30:00.000Z', today: '2026-03-29', now: '2026-03-29T00:30:00.000Z', plus3: '2026-04-01', minus1: '2026-03-28', nowPlus1: '2026-03-30T00:30:00.000Z' },
45+
{ at: '2026-11-01T23:30:00.000Z', today: '2026-11-01', now: '2026-11-01T23:30:00.000Z', plus3: '2026-11-04', minus1: '2026-10-31', nowPlus1: '2026-11-02T23:30:00.000Z' },
46+
] as const;
47+
48+
const REAL_TZ = process.env.TZ;
49+
afterEach(() => {
50+
if (REAL_TZ === undefined) delete process.env.TZ;
51+
else process.env.TZ = REAL_TZ;
52+
});
53+
54+
describe('the two shapes write the template dialect\'s bytes', () => {
55+
// A host zone east of every boundary instant's UTC day, one west, and UTC:
56+
// the renderer reads the UTC calendar only, so all three must agree.
57+
for (const hostZone of ['UTC', 'Pacific/Auckland', 'America/New_York']) {
58+
it(`over every instant, with the host process in ${hostZone}`, () => {
59+
process.env.TZ = hostZone;
60+
for (const row of TEMPLATE_BYTES) {
61+
expect(value('isoDate(today())', row.at), `{TODAY()} @ ${row.at}`).toBe(row.today);
62+
expect(value('isoDatetime(now())', row.at), `{NOW()} @ ${row.at}`).toBe(row.now);
63+
expect(value('isoDate(daysFromNow(3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3);
64+
expect(value('isoDate(addDays(today(), 3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3);
65+
expect(value('isoDate(daysAgo(1))', row.at), `{TODAY() - 1} @ ${row.at}`).toBe(row.minus1);
66+
expect(value('isoDatetime(addDays(now(), 1))', row.at), `{NOW() + 1} @ ${row.at}`).toBe(row.nowPlus1);
67+
}
68+
});
69+
}
70+
71+
it('isoDate renders the UTC calendar, so isoDate(today()) is the reference-timezone day', () => {
72+
// 23:30Z on Oct 8 is already Oct 9 in Auckland. today() under that
73+
// reference zone is Oct 9 at UTC midnight (ADR-0053 D1), and the instant
74+
// itself is still Oct 8 on the UTC calendar. A renderer that read the
75+
// reference zone would print the day before today() in every zone west of
76+
// UTC (the New York case below).
77+
const ctx = { now: new Date('2026-10-08T23:30:00.000Z'), timezone: 'Pacific/Auckland' };
78+
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, ctx)).toEqual({ ok: true, value: '2026-10-09' });
79+
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(now())' }, ctx)).toEqual({ ok: true, value: '2026-10-08' });
80+
const west = { now: new Date('2026-10-08T02:00:00.000Z'), timezone: 'America/New_York' };
81+
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, west)).toEqual({ ok: true, value: '2026-10-07' });
82+
});
83+
});
84+
85+
describe('the build agrees with the run', () => {
86+
it('accepts both spellings in a value slot and infers text', () => {
87+
for (const source of ['isoDate(today())', 'isoDatetime(now())', 'isoDate(daysFromNow(3))']) {
88+
expect(validateExpression('value', { dialect: 'cel', source }), source).toEqual({ ok: true, errors: [], warnings: [] });
89+
expect(inferExpressionType({ dialect: 'cel', source }), source).toBe('text');
90+
}
91+
});
92+
93+
it('refuses a misspelling with the unknown-function did-you-mean', () => {
94+
for (const [source, name, suggestion] of [
95+
['isoDte(today())', 'isoDte', 'isoDate'],
96+
['isoDateTime(now())', 'isoDateTime', 'isoDatetime'],
97+
] as const) {
98+
const v = validateExpression('value', { dialect: 'cel', source });
99+
expect(v.ok, source).toBe(false);
100+
expect(v.errors.map((e) => e.code), source).toEqual(['cel-unknown-function']);
101+
expect(v.errors[0].params, source).toMatchObject({ name, suggestion });
102+
expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false);
103+
}
104+
});
105+
106+
it('keeps string(timestamp) refused, so each shape has exactly one spelling', () => {
107+
// CEL defines string(timestamp) as RFC 3339 text that drops a zero
108+
// fraction (`…T00:00:00Z`), which is not the template's `.000Z`. A cel-js
109+
// upgrade that starts accepting it turns this red, and a person decides.
110+
for (const source of ['string(today())', 'string(now())']) {
111+
const v = validateExpression('value', { dialect: 'cel', source });
112+
expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']);
113+
expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source)
114+
.toContain('string(google.protobuf.Timestamp)');
115+
expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false);
116+
}
117+
});
118+
});
119+
120+
describe('a non-timestamp argument is refused loudly, never rendered', () => {
121+
it('at build, when the argument\'s type is known', () => {
122+
for (const [source, overload] of [
123+
["isoDate('2026-10-08')", 'isoDate(string)'],
124+
['isoDatetime(20261008)', 'isoDatetime(int)'],
125+
['isoDate(null)', 'isoDate(null)'],
126+
] as const) {
127+
const v = validateExpression('value', { dialect: 'cel', source });
128+
expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']);
129+
expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source).toContain(overload);
130+
}
131+
});
132+
133+
it('at run, when the value arrives as text, a number or null', () => {
134+
const record = { d: '2026-10-08', n: 5, z: null };
135+
for (const [source, overload] of [
136+
['isoDate(record.d)', 'isoDate(string)'],
137+
['isoDatetime(record.n)', 'isoDatetime(double)'],
138+
['isoDate(record.z)', 'isoDate(null)'],
139+
] as const) {
140+
const r = celEngine.evaluate({ dialect: 'cel', source }, { now: new Date(TEMPLATE_BYTES[0].at), record });
141+
expect(r.ok, source).toBe(false);
142+
if (!r.ok) {
143+
expect(r.error.kind, source).toBe('runtime');
144+
expect(r.error.message, source).toContain(overload);
145+
}
146+
}
147+
// The prescribed repair for ISO text: parse it first.
148+
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(date(record.d))' }, { record }))
149+
.toEqual({ ok: true, value: '2026-10-08' });
150+
});
151+
152+
it('at run, for an invalid or out-of-range timestamp', () => {
153+
for (const [source, fn] of [
154+
["isoDate(date('not a date'))", 'isoDate(t)'],
155+
["isoDatetime(addDays(timestamp('9999-12-31T00:00:00Z'), 1))", 'isoDatetime(t)'],
156+
["isoDate(addDays(timestamp('0001-01-01T00:00:00Z'), -1))", 'isoDate(t)'],
157+
] as const) {
158+
const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at));
159+
expect(r.ok, source).toBe(false);
160+
if (!r.ok) {
161+
expect(r.error.kind, source).toBe('runtime');
162+
expect(r.error.message, source).toContain(`${fn}: \`t\` is not a renderable timestamp`);
163+
}
164+
}
165+
});
166+
});
167+
168+
describe('no write path changes', () => {
169+
// Measured through a real AutomationEngine create_record over a real ObjectQL
170+
// engine and a recording driver: each of these reaches the store as a Date,
171+
// in a text, date or datetime column alike. Adding a spelling must not move
172+
// that, so the envelope's value is pinned as the same Date at the same instant.
173+
it('an envelope returning a timestamp still yields a Date', () => {
174+
const at = '2026-01-31T23:59:59.999Z';
175+
for (const [source, iso] of [
176+
['today()', '2026-01-31T00:00:00.000Z'],
177+
['now()', '2026-01-31T23:59:59.999Z'],
178+
['daysFromNow(3)', '2026-02-03T00:00:00.000Z'],
179+
['addDays(today(), 3)', '2026-02-03T00:00:00.000Z'],
180+
] as const) {
181+
const v = value(source, at);
182+
expect(v, source).toBeInstanceOf(Date);
183+
expect((v as Date).toISOString(), source).toBe(iso);
184+
}
185+
});
186+
});

‎packages/formula/src/stdlib.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ function toDate(v: unknown): Date {
8181
/** One UTC day in milliseconds. */
8282
const MS_PER_DAY = 86_400_000;
8383

84+
/**
85+
* The range cel-js's own `timestamp()` accepts, 0001-01-01T00:00:00Z through
86+
* 9999-12-31T23:59:59.999Z. Inside it `toISOString()` has one fixed shape,
87+
* `YYYY-MM-DDTHH:mm:ss.sssZ`; outside it the year expands (`+010000-…`).
88+
*/
89+
const MIN_TIMESTAMP_MS = -62_135_596_800_000;
90+
const MAX_TIMESTAMP_MS = 253_402_300_799_999;
91+
92+
/**
93+
* The ISO text of a timestamp: `toISOString()`, the UTC calendar, three-digit
94+
* milliseconds and a `Z`. These are the bytes the flow template dialect writes
95+
* for `{NOW()}`, and their first ten characters are what it writes for
96+
* `{TODAY()}` (`service-automation` `builtin/template.ts`, `resolveToken`).
97+
*
98+
* An invalid or out-of-range instant is refused rather than rendered. The
99+
* refusal is a throw, which the engine reports as a runtime error.
100+
*/
101+
function isoTimestampText(fn: string, d: Date): string {
102+
const ms = d.getTime();
103+
if (!(ms >= MIN_TIMESTAMP_MS && ms <= MAX_TIMESTAMP_MS)) {
104+
throw new Error(
105+
`${fn}(t): \`t\` is not a renderable timestamp (${Number.isNaN(ms) ? 'an invalid date' : 'outside 0001-01-01 … 9999-12-31'}). ` +
106+
`Pass a valid timestamp, e.g. ${fn}(${fn === 'isoDate' ? 'today()' : 'now()'}), or ISO text through date(…).`,
107+
);
108+
}
109+
return d.toISOString();
110+
}
111+
84112
/** Add `n` days to a Date in UTC; returns a new Date. */
85113
function addDaysUtc(d: Date, n: number): Date {
86114
const out = new Date(d.getTime());
@@ -333,6 +361,31 @@ export function registerStdLib(
333361
// intent); kept distinct because authors reach for whichever reads clearer.
334362
.registerFunction('date(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
335363
.registerFunction('datetime(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
364+
// The string form of a timestamp, the reverse of `date` / `datetime`:
365+
// `isoDate(t)` is `YYYY-MM-DD` and `isoDatetime(t)` is
366+
// `YYYY-MM-DDTHH:mm:ss.sssZ`, both on the UTC calendar. They write the
367+
// bytes the flow template dialect writes for `{TODAY()}` and `{NOW()}`.
368+
// `isoDate(today())` is the reference-timezone day, because `today()` is
369+
// that day at UTC midnight (ADR-0053 D1).
370+
//
371+
// Two named functions, not a `string(timestamp)` overload. A `string()`
372+
// overload answers one shape for one type, so it cannot spell the date
373+
// shape. CEL also defines `string(timestamp)` as RFC 3339 text that drops
374+
// zero fractions, so matching the template's `.000` would make `string()`
375+
// a dialect. That name stays refused for a timestamp, which a test pins.
376+
//
377+
// The parameter is a timestamp, never `dyn`. Text, a number or `null` is
378+
// refused: at build when the argument's type is known, at run otherwise.
379+
// Coercing like `toDate` would parse non-ISO text in the host's local
380+
// zone and could render a different day.
381+
.registerFunction(
382+
'isoDate(google.protobuf.Timestamp): string',
383+
(d: Date) => isoTimestampText('isoDate', d).slice(0, 10),
384+
)
385+
.registerFunction(
386+
'isoDatetime(google.protobuf.Timestamp): string',
387+
(d: Date) => isoTimestampText('isoDatetime', d),
388+
)
336389
// ── Numbers ──────────────────────────────────────────────────────────
337390
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
338391
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))

‎packages/formula/src/validate.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,16 +1112,17 @@ export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint):
11121112
*
11131113
* ## This is a CURATED SUBSET of what the environment resolves — by construction
11141114
*
1115-
* The evaluation `Environment` resolves **72** distinct function names. This list
1116-
* carries 35 of them, and the 37-name gap is NOT staleness. Measured decomposition
1115+
* The evaluation `Environment` resolves **75** distinct function names. This list
1116+
* carries 37 of them, and the 38-name gap is NOT staleness. Measured decomposition
11171117
* (`cel-stdlib-drift.test.ts` re-measures all four numbers on every run):
11181118
*
1119-
* 72 registered names
1120-
* = 39 callable BARE, as `fn(x)` -> the only shape this list may carry
1121-
* + 33 callable only on a RECEIVER, `x.fn()` -> structurally ineligible
1119+
* 75 registered names
1120+
* = 41 callable BARE, as `fn(x)` -> the only shape this list may carry
1121+
* + 34 callable only on a RECEIVER, `x.fn()` -> structurally ineligible
1122+
* (cel-js's 33, plus our `can`)
11221123
*
1123-
* 39 bare-callable
1124-
* = 27 added by `registerStdLib` -> ALL advertised (one per registration site)
1124+
* 41 bare-callable
1125+
* = 29 added by `registerStdLib` -> ALL advertised (one per registration site)
11251126
* + 8 cel-js built-ins -> advertised: has size int string bool double
11261127
* timestamp duration
11271128
* + 4 cel-js built-ins WITHHELD -> bytes dyn type uint
@@ -1144,13 +1145,14 @@ export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint):
11441145
* cel-js built-in cannot arrive unnoticed.
11451146
*
11461147
* ⛔ This list is NOT an oracle for rejecting unknown functions. A gate that
1147-
* rejects what is absent here would reject 37 names that resolve and evaluate
1148+
* rejects what is absent here would reject 38 names that resolve and evaluate
11481149
* today. The unknown-function verdict belongs to the engine's own `check()`
11491150
* (ruling on #13594); `@objectstack/lint` uses that and never reads this list.
11501151
*/
11511152
export const CEL_STDLIB_FUNCTIONS: string[] = [
11521153
// Dates (registered stdlib)
11531154
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime',
1155+
'isoDate', 'isoDatetime',
11541156
// Numbers (registered stdlib)
11551157
'abs', 'round', 'floor', 'ceil', 'min', 'max',
11561158
// Strings (registered stdlib)

0 commit comments

Comments
 (0)