Skip to content

Commit 345d3f3

Browse files
feat(formula): isoDate(t) / isoDatetime(t), the string form of a CEL timestamp (#22347)
Fixes #22277 Clause-②: yes (widening: CEL gains a string form for a Timestamp; no envelope that returns a Timestamp changes what it writes) ## What this adds Two CEL stdlib functions in `@objectstack/formula` render a timestamp as ISO text on the UTC calendar: | Spelling | Returns | Writes what the flow template wrote for | |:---|:---|:---| | `isoDate(t)` | `YYYY-MM-DD` | `{TODAY()}` as `isoDate(today())`, `{TODAY() + n}` as `isoDate(daysFromNow(n))`, `{TODAY() - n}` as `isoDate(daysAgo(n))` | | `isoDatetime(t)` | `YYYY-MM-DDTHH:mm:ss.sssZ` | `{NOW()}` as `isoDatetime(now())`, `{NOW() + n}` as `isoDatetime(addDays(now(), n))` | Both join `CEL_STDLIB_FUNCTIONS`, so `introspectScope` advertises them and the build check accepts what the run evaluates. They also join the formula skill's stdlib table and the formulas docs page, which the drift pins hold equal to the catalog. This is the string form #11182's ruling D names as the remedy for the date macros in flow value slots. #19939's pass 3 (refusing `{NOW()}` / `{TODAY() ± N}` with this as the remedy) is not in this PR and remains open. ## Why two named functions, not a `string(timestamp)` overload The ruling asks for one spelling: an overload or a named function. This PR uses named functions, one name per shape. 1. **A `string()` overload cannot spell the date shape.** One overload answers one shape for one type. `string(today())` could only be date-time text (`…T00:00:00.000Z`), never the `YYYY-MM-DD` that `{TODAY()}` wrote. 2. **A byte match would make `string()` a dialect.** CEL defines `string(timestamp)` as RFC 3339 text that drops a zero fraction (`2026-10-08T00:00:00Z`). The template always writes `.000Z`. An overload that matched the template would answer `string()` differently from CEL. An AI author who knows CEL would not see that difference. 3. **It is safe in cel-js, but it can collide later (measured, P3).** cel-js 8.0.0 accepts `registerFunction('string(google.protobuf.Timestamp): string', …)`. A second registration of the same signature throws `overlaps with existing overload` when the environment is built. So a cel-js upgrade that ships its own `string(timestamp)` would break every environment build. Named functions avoid that. 4. **The names say the shape.** They reverse `date(s)` / `datetime(s)`, and they match the `{{ x | date:iso }}` / `{{ x | datetime:iso }}` formatters, which produce the same bytes. `datetime` stays one lower-case word, as in the field type and `datetime(s)`. A camel-cased `isoDateTime(…)` gets the existing did-you-mean, which suggests `isoDatetime` (pinned). `string(today())` stays refused, and a test pins it. A cel-js upgrade that starts accepting it turns that test red, and a person decides. **Only a timestamp is accepted.** The parameter is `google.protobuf.Timestamp`, never `dyn`. Text, a number or `null` is refused at build when the argument's type is known (`isoDate('2026-10-08')`) and at run otherwise (`isoDate(record.d)` where `d` holds text). It is never coerced. Coercing through the stdlib's `toDate` would parse non-ISO text in the host's local zone and could render a different day. The repair for ISO text is `isoDate(date(s))`. An invalid timestamp, or one outside cel-js's own `timestamp()` range (0001-01-01 to 9999-12-31), is refused at run, because outside that range `toISOString()` changes shape (`+010000-…`). ## Premises, measured before any behaviour change (at `28bff18d0c`) **P1 holds.** Measured through the built `dist` with `now` pinned at `2026-10-08T17:55:06.123Z`: `now()`, `today()`, `daysFromNow(3)`, `daysAgo(1)`, `addDays(today(), 3)` and `addDays(now(), 3)` each return a `Date`. `string(today())`, `string(now())` and `string(daysFromNow(3))` fault `found no matching overload for 'string(google.protobuf.Timestamp)'` at run. `validateExpression` refuses them `invalid-cel` in the `value`, `predicate` and `formula` roles. This matches #19939 pass 1's probes D6 and X13. **P2 holds, byte for byte.** `interpolateString` (the shipped source, run through tsx) was measured with a fixed global clock at 9 instants under 6 host process zones (`UTC`, `America/New_York`, `Europe/Berlin`, `Asia/Kolkata`, `Pacific/Auckland`, `Pacific/Honolulu`). Each cell was compared with the new spelling over the Timestamp the flow value-slot CEL scope produces. That scope is `AutomationEngine.celScope`, which passes no `now` and no `timezone`, so it runs on the wall clock and the UTC calendar. Result: **378 cells, 0 differences**. No template cell depended on the host zone. Its output is a string in every cell. | Instant | `{TODAY()}` | `{NOW()}` | `{TODAY() + 3}` | `{TODAY() - 1}` | `{NOW() + 1}` | |:---|:---|:---|:---|:---|:---| | `2026-10-08T17:55:06.123Z` | `2026-10-08` | `2026-10-08T17:55:06.123Z` | `2026-10-11` | `2026-10-07` | `2026-10-09T17:55:06.123Z` | | `2026-10-08T00:00:00.000Z` (UTC midnight, `.000`) | `2026-10-08` | `2026-10-08T00:00:00.000Z` | `2026-10-11` | `2026-10-07` | `2026-10-09T00:00:00.000Z` | | `2026-01-31T23:59:59.999Z` (month end) | `2026-01-31` | `2026-01-31T23:59:59.999Z` | `2026-02-03` | `2026-01-30` | `2026-02-01T23:59:59.999Z` | | `2026-02-28T12:00:00.000Z` (Feb to Mar) | `2026-02-28` | `2026-02-28T12:00:00.000Z` | `2026-03-03` | `2026-02-27` | `2026-03-01T12:00:00.000Z` | | `2028-02-28T12:00:00.000Z` (leap) | `2028-02-28` | `2028-02-28T12:00:00.000Z` | `2028-03-02` | `2028-02-27` | `2028-02-29T12:00:00.000Z` | | `2026-12-31T23:30:00.000Z` (year end) | `2026-12-31` | `2026-12-31T23:30:00.000Z` | `2027-01-03` | `2026-12-30` | `2027-01-01T23:30:00.000Z` | | `2026-03-08T07:30:00.000Z` (US spring-forward) | `2026-03-08` | `2026-03-08T07:30:00.000Z` | `2026-03-11` | `2026-03-07` | `2026-03-09T07:30:00.000Z` | | `2026-03-29T00:30:00.000Z` (EU spring-forward) | `2026-03-29` | `2026-03-29T00:30:00.000Z` | `2026-04-01` | `2026-03-28` | `2026-03-30T00:30:00.000Z` | | `2026-11-01T23:30:00.000Z` (US fall-back) | `2026-11-01` | `2026-11-01T23:30:00.000Z` | `2026-11-04` | `2026-10-31` | `2026-11-02T23:30:00.000Z` | - **Timezone:** UTC only (`toISOString()`). The host zone has no effect. - **Precision:** always three-digit milliseconds, including `.000`. - **Suffix:** always `Z`, never an offset. - **`± N`:** whole UTC days through `setUTCDate`, so a month, year or DST boundary moves the date and never the clock time. A variable offset (`{TODAY() + n}`) works the same way. A non-numeric offset becomes 0. - **Embedded use:** `due {TODAY()} at {NOW()}` interpolates the same bytes into a string. `today()` follows the evaluation's reference timezone (ADR-0053 D1). The flow value-slot scope sets none, so it is the UTC day, the same day `{TODAY()}` writes. The P2 control measured the other case. At `2026-10-08T23:30Z` with `ctx.timezone = Pacific/Auckland`, `isoDate(today())` is `2026-10-09` (the reference day), while `isoDate(now())` and `{TODAY()}` are `2026-10-08`. The renderer reads the UTC calendar because that is the only reading that keeps `today()`'s UTC-midnight representation on its own day in zones west of UTC. A pin holds that. **P3 holds.** See point 3 above: the overload was possible, and the named functions were chosen on points 1, 2 and 4. **P4: the build-side readers.** `validateExpression` / `celEngine.compile` type-check through the same `registerStdLib` the run evaluates, so registering the names is what makes the build accept them. At BASE the build refused `isoDate(today())` as `cel-unknown-function` and the run refused it too. Now both accept it, and `inferExpressionType` answers `text`. The readers that had to learn the names, each held by an existing pin: - `CEL_STDLIB_FUNCTIONS` (`cel-stdlib-drift.test.ts` B: every bare-callable `registerStdLib` adds must be advertised); - `skills/objectstack-formula/SKILL.md` (`skill-catalog-sync.test.ts`; measured red until the row landed); - `cel-engine.test.ts`'s runtime probe map (every advertised name needs a bare-call probe); - the formulas docs page's stdlib table. The did-you-mean (`nearestCallable`) reads `CEL_STDLIB_FUNCTIONS`, so it suggests the new names with no further change. `firstUnknownFunctionCall` and `@objectstack/lint`'s visibility gate read the environment and needed no change. The `validate.ts` decomposition comment is re-measured: 75 registered names = 41 bare plus 34 receiver-only (cel-js's 33 plus our `can`), 29 bare names added by `registerStdLib`, 37 advertised, a gap of 38. At BASE it said 72 / 33 / 27 / 35 / 37, already stale by `can`. ## No write path changes (measured through the data engine) This uses a real `AutomationEngine` and `create_record` over a real `ObjectQL` engine with a recording driver, clock fixed at `2026-01-31T23:59:59.999Z`, run on BASE and on this head: | `fields.*` value | Column type | BASE writes | This head writes | |:---|:---|:---|:---| | CEL `today()` | text, date | `Date` `2026-01-31T00:00:00.000Z` | `Date` `2026-01-31T00:00:00.000Z` | | CEL `now()` | text, datetime | `Date` `2026-01-31T23:59:59.999Z` | `Date` `2026-01-31T23:59:59.999Z` | | CEL `daysFromNow(3)`, `addDays(today(), 3)` | text | `Date` `2026-02-03T00:00:00.000Z` | `Date` `2026-02-03T00:00:00.000Z` | | template `{TODAY()}` | text, date | `"2026-01-31"` | `"2026-01-31"` | | template `{NOW()}` | text, datetime | `"2026-01-31T23:59:59.999Z"` | `"2026-01-31T23:59:59.999Z"` | | template `{TODAY() + 3}` / `{TODAY() - 1}` | text | `"2026-02-03"` / `"2026-01-30"` | `"2026-02-03"` / `"2026-01-30"` | | CEL `isoDate(today())` | text, date | refused at BASE (`cel-unknown-function`) | `"2026-01-31"` | | CEL `isoDatetime(now())` | text, datetime | refused at BASE | `"2026-01-31T23:59:59.999Z"` | | CEL `isoDate(daysFromNow(3))` / `isoDate(daysAgo(1))` | text | refused at BASE | `"2026-02-03"` / `"2026-01-30"` | Every row that existed at BASE is byte-identical on this head (diffed). The new spellings write the same strings the template macros write, into the same columns. The probe ran in the scratchpad and is not committed, because `service-automation` is not this card's to touch. ## Pins (`packages/formula/src/stdlib-timestamp-text.test.ts`) - **The two shapes:** for every instant in the P2 table, `isoDate(today())`, `isoDatetime(now())`, `isoDate(daysFromNow(3))`, `isoDate(addDays(today(), 3))`, `isoDate(daysAgo(1))` and `isoDatetime(addDays(now(), 1))` equal the template's measured bytes. This runs with the host process in `UTC`, `Pacific/Auckland` and `America/New_York`. - **The reference day:** `isoDate(today())` under `Pacific/Auckland` and under `America/New_York` is the reference day, and `isoDate(now())` is the UTC day. - **The build agrees with the run:** both spellings validate clean in a value slot and infer `text`. `isoDte(…)` and `isoDateTime(…)` are refused `cel-unknown-function` with `params.suggestion` `isoDate` and `isoDatetime`. `string(today())` and `string(now())` stay refused (`invalid-cel`, naming `string(google.protobuf.Timestamp)`). - **Non-timestamp arguments:** literal text, an int and `null` are refused at build (`invalid-cel`, naming the overload). Text, a double and `null` arriving through a binding are refused at run (`runtime`). An invalid timestamp and both range edges are refused at run with the function's own message. `isoDate(date(record.d))` is the repair, and it works. - **No write path changes:** `today()`, `now()`, `daysFromNow(3)` and `addDays(today(), 3)` still evaluate to a `Date` at the same instant. The live cross-dialect parity pin (`interpolateString` against the envelope in one test) belongs in `service-automation`, which this card does not touch. #19939's pass 3 is the natural carrier. `crud-fields-value-envelope.test.ts` already pins each refused spelling beside "the CEL spelling that writes the same value". Here the template's bytes are recorded as measured literals. ## Reverse verification The implementation was committed first (`eebccf401b`). Then both `registerFunction` calls were deleted from `stdlib.ts` through `scripts/ablation-replace.mjs` (anchor 1 to 0 hits; blob `ecf451d4074e` to `0349b05f6061`), and the four files that read the registration were run. Predicted direction: red. Observed: **9 failed, 92 passed (101)**. - **Red:** the three host-zone shape pins; the reference-day pin; the build-accept pin; both run-refusal rows, because the `date()` repair and the function's own range message disappear; `cel-stdlib-drift` A (advertised but not registered); and `cel-engine.test.ts`'s runtime probe. - **Still green, as they should be:** the misspelling did-you-mean (it reads the catalog), the `string(timestamp)` refusal, the literal-type build refusals (nothing accepts those either way) and the no-write-path pin. The restore is proven, not assumed: the blob after restore equals the HEAD blob `ecf451d4074e`, and `git diff HEAD` and `git status --porcelain` are both empty. The tests import `src` by relative path, so no `dist` is in the resolution path and no rebuild leg applies. ## Tests and gates (at `eebccf401b`) - `pnpm --filter @objectstack/formula build`: exit 0. `dist` was rebuilt from this head before any gate that reads it. - `pnpm --filter @objectstack/formula test`, the full task: **44 files, 1268 tests passed**. - `pnpm --filter @objectstack/formula typecheck`: exit 0. `tsc --listFiles -p tsconfig.test.json` compiles 44 of 44 test files, and the new one has 0 errors. The 7 errors in that program are the ledgered debt in 3 other files, which `check:test-typecheck` holds. - Import side: `@objectstack/lint` `validate-visibility-predicates.test.ts`, which reads `CEL_STDLIB_FUNCTIONS` from the built package: 185 passed. - Gates: the claim-time list (57) united with `dispatch-gates --commands` re-derived at this head (97), 99 commands, each exit code captured before any pipe. `--ran` reconciliation: **97 derived, 96 run, 1 NOT MEASURED, 0 unrun**. The 2 claim-time families outside this derivation (`check:dispatcher-error-vocabulary`, `check:swallow-census-controls`) also ran and exited 0. - `check:skill-examples` first exited 3 (prerequisite: `@objectstack/client-react` was not built). After that closure was built it exited 0: 262 examples type-check. - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: exit 3, because 37 workspace packages have no `dist` here, and a whole-workspace build is CI's to run. Declared narrowing, which measures that gate's property on the one package this diff touches: `require` of formula's published entry (`./dist/index.js`) loads, and it evaluates `isoDate(today()) + " " + isoDatetime(now())` to `2026-10-08 2026-10-08T17:55:06.123Z`. - `check:skills-token-ratchet`: `skills/objectstack-formula/SKILL.md is 5403 tokens (ceiling 6002; headroom 599)`. - Size: 7 files, +267 / -9 against the merge base `28bff18d0c`. ## File surface The landing is the claim's: `packages/formula/src/stdlib.ts` and `validate.ts`, their tests, the stdlib catalogs and the formulas docs page. `cel-engine.ts`, `types.ts` and `index.ts` did not need to change, because the overload route was not taken and no type or export was added. One file the claim did not list by name is `skills/objectstack-formula/SKILL.md`. It is the stdlib catalog `skill-catalog-sync.test.ts` pins, and that pin measured red until the row landed. It is a governed path (`skills/**`, Tier H), so the landing tier is set by that file. ## The skills surface (two readings) `skills/objectstack-formula/SKILL.md` gains one table row (`skill-catalog-sync.test.ts` requires every advertised name to be documented there): | Reading | Before | After | |:---|:---|:---| | `skills/objectstack-formula/SKILL.md` lines | 461 | 462 | | same file, tokens (`ceil(bytes / 4)`, the ratchet's unit) | 5367 | 5403 (ceiling 6002) | | whole pack, all 10 `skills/*/SKILL.md`, lines | 4409 | 4410 | | whole pack, tokens (sum of per-file counts) | 52556 | 52592 | `skills/**` is a governed surface (Tier H), so this PR lands only on the maintainer's approval. ## 维护者速读(草稿) **改了什么**:公式引擎(CEL)新增两个函数 `isoDate(t)` / `isoDatetime(t)`,把时间戳写成文本:`2026-10-08` 与 `2026-10-08T17:55:06.123Z`。目录、技能表、文档各加一行。 **为什么改**:流程值槽里的 `{TODAY()}` / `{NOW()}` 模板写的是这两种文本,而 CEL 只能产出时间戳对象(落库是 `Date`)。#11182 裁决 D 要求先有"字符串形式",#19939 才能拒收这些模板写法并给出等价写法。实测 378 个组合逐字节一致。 **风险与代价(含回滚)**:纯新增,已有表达式与写入路径不变(实测仍写 `Date`)。只收时间戳,传文本/数字/null 会响亮报错。回滚即删两处注册与目录行。因触及 `skills/**`,本 PR 属 Tier H。 **席位意见**: **你要做的**:审批本 PR(Tier H 需维护者批准)。若更倾向于 `string(timestamp)` 重载这一拼写,请在此指出。 ## Acceptance notes - **Out-of-surface comment counts, not edited:** `packages/formula/src/unknown-function.ts` (lines 39 to 41) and `packages/lint/src/validate-visibility-predicates.test.ts` (line 1457) state "advertises 35 / registers 72 / 37-name gap" in the present tense. They were already stale by `can` (73 / 38) and now read 37 / 75 / 38. Comments only, no behaviour. Carrier: none named; whoever next touches either file. - **QA checklist count:** `docs/qa/platform-checklist/areas/api-backend.json` item `api-backend.formula-stdlib-matrix` titles itself "all 27 registered functions". `registerStdLib` now registers 29 bare-callable functions. The item recounts from source at its step 1 and is not a per-PR gate. Carrier: the next `checklist-author` sweep or a run of that item. - **For #19939 pass 3, measured:** a fractional negative offset differs. `{TODAY() - 1.5}` writes `2026-10-06` (`setUTCDate` truncates the sum), while `isoDate(addDays(today(), -1.5))` writes `2026-10-07` (`addDays` truncates the offset). `daysAgo(1.5)` is refused at build (an int parameter). The template documents integer offsets only, so the remedy mapping holds for integer N. Also, `{NOW() ± n}` maps to `isoDatetime(addDays(now(), ±n))`, not to `daysFromNow`, which lands on midnight. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad381fd commit 345d3f3

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)