Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/22277-cel-timestamp-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/formula": minor
---

feat(formula): `isoDate(t)` and `isoDatetime(t)`, the string form of a CEL timestamp

Clause-②: yes (widening)

- **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.
- **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.
- **`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.
- **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.
- **`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.
- **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.
1 change: 1 addition & 0 deletions content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ All functions are pure given a pinned `now`, which is what makes
| `daysFromNow(n)` / `daysAgo(n)` | timestamp | `today() ± n` days (calendar-day, not wall-clock) |
| `addDays(d, n)` / `addMonths(d, n)` | timestamp | Shift a *given* date; `addMonths` clamps to month end (Jan 31 + 1mo → Feb 28) |
| `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string (aliases) |
| `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 |
| `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative when `b` is earlier) |
| `isBlank(v)` | bool | True for `null`, `undefined`, `''`, `[]` |
| `isEmpty(v)` | bool | True for `null` or zero-length string/list/map |
Expand Down
3 changes: 2 additions & 1 deletion packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ describe('celEngine', () => {
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)',
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
datetime: 'datetime("2026-03-15T08:00:00Z")', isoDate: 'isoDate(today())',
isoDatetime: 'isoDatetime(now())', abs: 'abs(-3.5)', round: 'round(2.6)',
floor: 'floor(6.7)', ceil: 'ceil(6.1)',
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',
Expand Down
186 changes: 186 additions & 0 deletions packages/formula/src/stdlib-timestamp-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from 'vitest';

import { celEngine } from './cel-engine';
import { inferExpressionType, validateExpression } from './validate';

/**
* `isoDate(t)` / `isoDatetime(t)` — the string form of a CEL timestamp.
*
* ## What they must reproduce
*
* The flow template dialect's date macros (`service-automation`
* `builtin/template.ts`, `resolveToken`) write `new Date().toISOString()` for
* `{NOW()}`, its first ten characters for `{TODAY()}`, and shift the instant by
* whole UTC days (`setUTCDate`) for `{TODAY() ± N}` / `{NOW() ± N}`. The
* expected strings in {@link TEMPLATE_BYTES} are that function's own output,
* measured through `interpolateString` at these instants under six host
* zones. The host zone changed no byte of it.
*
* ## The scope these pins evaluate in
*
* A flow value envelope is evaluated with `AutomationEngine.celScope`, which
* passes `{ extra, record }` and nothing else, so the engine runs on the UTC
* calendar with the wall clock. Here `now` pins the clock to the row's instant
* and nothing else differs.
*/

const flowScope = (at: string) => ({ now: new Date(at), extra: {}, record: {} });

function value(source: string, at: string): unknown {
const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(at));
if (!r.ok) throw new Error(`${source} @ ${at}: ${r.error.kind}: ${r.error.message}`);
return r.value;
}

/** `interpolateString` output at each instant, per token (measured; see the header). */
const TEMPLATE_BYTES = [
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
] as const;

const REAL_TZ = process.env.TZ;
afterEach(() => {
if (REAL_TZ === undefined) delete process.env.TZ;
else process.env.TZ = REAL_TZ;
});

describe('the two shapes write the template dialect\'s bytes', () => {
// A host zone east of every boundary instant's UTC day, one west, and UTC:
// the renderer reads the UTC calendar only, so all three must agree.
for (const hostZone of ['UTC', 'Pacific/Auckland', 'America/New_York']) {
it(`over every instant, with the host process in ${hostZone}`, () => {
process.env.TZ = hostZone;
for (const row of TEMPLATE_BYTES) {
expect(value('isoDate(today())', row.at), `{TODAY()} @ ${row.at}`).toBe(row.today);
expect(value('isoDatetime(now())', row.at), `{NOW()} @ ${row.at}`).toBe(row.now);
expect(value('isoDate(daysFromNow(3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3);
expect(value('isoDate(addDays(today(), 3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3);
expect(value('isoDate(daysAgo(1))', row.at), `{TODAY() - 1} @ ${row.at}`).toBe(row.minus1);
expect(value('isoDatetime(addDays(now(), 1))', row.at), `{NOW() + 1} @ ${row.at}`).toBe(row.nowPlus1);
}
});
}

it('isoDate renders the UTC calendar, so isoDate(today()) is the reference-timezone day', () => {
// 23:30Z on Oct 8 is already Oct 9 in Auckland. today() under that
// reference zone is Oct 9 at UTC midnight (ADR-0053 D1), and the instant
// itself is still Oct 8 on the UTC calendar. A renderer that read the
// reference zone would print the day before today() in every zone west of
// UTC (the New York case below).
const ctx = { now: new Date('2026-10-08T23:30:00.000Z'), timezone: 'Pacific/Auckland' };
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, ctx)).toEqual({ ok: true, value: '2026-10-09' });
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(now())' }, ctx)).toEqual({ ok: true, value: '2026-10-08' });
const west = { now: new Date('2026-10-08T02:00:00.000Z'), timezone: 'America/New_York' };
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, west)).toEqual({ ok: true, value: '2026-10-07' });
});
});

describe('the build agrees with the run', () => {
it('accepts both spellings in a value slot and infers text', () => {
for (const source of ['isoDate(today())', 'isoDatetime(now())', 'isoDate(daysFromNow(3))']) {
expect(validateExpression('value', { dialect: 'cel', source }), source).toEqual({ ok: true, errors: [], warnings: [] });
expect(inferExpressionType({ dialect: 'cel', source }), source).toBe('text');
}
});

it('refuses a misspelling with the unknown-function did-you-mean', () => {
for (const [source, name, suggestion] of [
['isoDte(today())', 'isoDte', 'isoDate'],
['isoDateTime(now())', 'isoDateTime', 'isoDatetime'],
] as const) {
const v = validateExpression('value', { dialect: 'cel', source });
expect(v.ok, source).toBe(false);
expect(v.errors.map((e) => e.code), source).toEqual(['cel-unknown-function']);
expect(v.errors[0].params, source).toMatchObject({ name, suggestion });
expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false);
}
});

it('keeps string(timestamp) refused, so each shape has exactly one spelling', () => {
// CEL defines string(timestamp) as RFC 3339 text that drops a zero
// fraction (`…T00:00:00Z`), which is not the template's `.000Z`. A cel-js
// upgrade that starts accepting it turns this red, and a person decides.
for (const source of ['string(today())', 'string(now())']) {
const v = validateExpression('value', { dialect: 'cel', source });
expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']);
expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source)
.toContain('string(google.protobuf.Timestamp)');
expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false);
}
});
});

describe('a non-timestamp argument is refused loudly, never rendered', () => {
it('at build, when the argument\'s type is known', () => {
for (const [source, overload] of [
["isoDate('2026-10-08')", 'isoDate(string)'],
['isoDatetime(20261008)', 'isoDatetime(int)'],
['isoDate(null)', 'isoDate(null)'],
] as const) {
const v = validateExpression('value', { dialect: 'cel', source });
expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']);
expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source).toContain(overload);
}
});

it('at run, when the value arrives as text, a number or null', () => {
const record = { d: '2026-10-08', n: 5, z: null };
for (const [source, overload] of [
['isoDate(record.d)', 'isoDate(string)'],
['isoDatetime(record.n)', 'isoDatetime(double)'],
['isoDate(record.z)', 'isoDate(null)'],
] as const) {
const r = celEngine.evaluate({ dialect: 'cel', source }, { now: new Date(TEMPLATE_BYTES[0].at), record });
expect(r.ok, source).toBe(false);
if (!r.ok) {
expect(r.error.kind, source).toBe('runtime');
expect(r.error.message, source).toContain(overload);
}
}
// The prescribed repair for ISO text: parse it first.
expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(date(record.d))' }, { record }))
.toEqual({ ok: true, value: '2026-10-08' });
});

it('at run, for an invalid or out-of-range timestamp', () => {
for (const [source, fn] of [
["isoDate(date('not a date'))", 'isoDate(t)'],
["isoDatetime(addDays(timestamp('9999-12-31T00:00:00Z'), 1))", 'isoDatetime(t)'],
["isoDate(addDays(timestamp('0001-01-01T00:00:00Z'), -1))", 'isoDate(t)'],
] as const) {
const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at));
expect(r.ok, source).toBe(false);
if (!r.ok) {
expect(r.error.kind, source).toBe('runtime');
expect(r.error.message, source).toContain(`${fn}: \`t\` is not a renderable timestamp`);
}
}
});
});

describe('no write path changes', () => {
// Measured through a real AutomationEngine create_record over a real ObjectQL
// engine and a recording driver: each of these reaches the store as a Date,
// in a text, date or datetime column alike. Adding a spelling must not move
// that, so the envelope's value is pinned as the same Date at the same instant.
it('an envelope returning a timestamp still yields a Date', () => {
const at = '2026-01-31T23:59:59.999Z';
for (const [source, iso] of [
['today()', '2026-01-31T00:00:00.000Z'],
['now()', '2026-01-31T23:59:59.999Z'],
['daysFromNow(3)', '2026-02-03T00:00:00.000Z'],
['addDays(today(), 3)', '2026-02-03T00:00:00.000Z'],
] as const) {
const v = value(source, at);
expect(v, source).toBeInstanceOf(Date);
expect((v as Date).toISOString(), source).toBe(iso);
}
});
});
53 changes: 53 additions & 0 deletions packages/formula/src/stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,34 @@ function toDate(v: unknown): Date {
/** One UTC day in milliseconds. */
const MS_PER_DAY = 86_400_000;

/**
* The range cel-js's own `timestamp()` accepts, 0001-01-01T00:00:00Z through
* 9999-12-31T23:59:59.999Z. Inside it `toISOString()` has one fixed shape,
* `YYYY-MM-DDTHH:mm:ss.sssZ`; outside it the year expands (`+010000-…`).
*/
const MIN_TIMESTAMP_MS = -62_135_596_800_000;
const MAX_TIMESTAMP_MS = 253_402_300_799_999;

/**
* The ISO text of a timestamp: `toISOString()`, the UTC calendar, three-digit
* milliseconds and a `Z`. These are the bytes the flow template dialect writes
* for `{NOW()}`, and their first ten characters are what it writes for
* `{TODAY()}` (`service-automation` `builtin/template.ts`, `resolveToken`).
*
* An invalid or out-of-range instant is refused rather than rendered. The
* refusal is a throw, which the engine reports as a runtime error.
*/
function isoTimestampText(fn: string, d: Date): string {
const ms = d.getTime();
if (!(ms >= MIN_TIMESTAMP_MS && ms <= MAX_TIMESTAMP_MS)) {
throw new Error(
`${fn}(t): \`t\` is not a renderable timestamp (${Number.isNaN(ms) ? 'an invalid date' : 'outside 0001-01-01 … 9999-12-31'}). ` +
`Pass a valid timestamp, e.g. ${fn}(${fn === 'isoDate' ? 'today()' : 'now()'}), or ISO text through date(…).`,
);
}
return d.toISOString();
}

/** Add `n` days to a Date in UTC; returns a new Date. */
function addDaysUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
Expand Down Expand Up @@ -333,6 +361,31 @@ export function registerStdLib(
// intent); kept distinct because authors reach for whichever reads clearer.
.registerFunction('date(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
.registerFunction('datetime(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
// The string form of a timestamp, the reverse of `date` / `datetime`:
// `isoDate(t)` is `YYYY-MM-DD` and `isoDatetime(t)` is
// `YYYY-MM-DDTHH:mm:ss.sssZ`, both on the UTC calendar. They write the
// bytes the flow template dialect writes for `{TODAY()}` and `{NOW()}`.
// `isoDate(today())` is the reference-timezone day, because `today()` is
// that day at UTC midnight (ADR-0053 D1).
//
// Two named functions, not a `string(timestamp)` overload. A `string()`
// overload answers one shape for one type, so it cannot spell the date
// shape. CEL also defines `string(timestamp)` as RFC 3339 text that drops
// zero fractions, so matching the template's `.000` would make `string()`
// a dialect. That name stays refused for a timestamp, which a test pins.
//
// The parameter is a timestamp, never `dyn`. Text, a number or `null` is
// refused: at build when the argument's type is known, at run otherwise.
// Coercing like `toDate` would parse non-ISO text in the host's local
// zone and could render a different day.
.registerFunction(
'isoDate(google.protobuf.Timestamp): string',
(d: Date) => isoTimestampText('isoDate', d).slice(0, 10),
)
.registerFunction(
'isoDatetime(google.protobuf.Timestamp): string',
(d: Date) => isoTimestampText('isoDatetime', d),
)
// ── Numbers ──────────────────────────────────────────────────────────
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))
Expand Down
18 changes: 10 additions & 8 deletions packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,16 +1112,17 @@ export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint):
*
* ## This is a CURATED SUBSET of what the environment resolves — by construction
*
* The evaluation `Environment` resolves **72** distinct function names. This list
* carries 35 of them, and the 37-name gap is NOT staleness. Measured decomposition
* The evaluation `Environment` resolves **75** distinct function names. This list
* carries 37 of them, and the 38-name gap is NOT staleness. Measured decomposition
* (`cel-stdlib-drift.test.ts` re-measures all four numbers on every run):
*
* 72 registered names
* = 39 callable BARE, as `fn(x)` -> the only shape this list may carry
* + 33 callable only on a RECEIVER, `x.fn()` -> structurally ineligible
* 75 registered names
* = 41 callable BARE, as `fn(x)` -> the only shape this list may carry
* + 34 callable only on a RECEIVER, `x.fn()` -> structurally ineligible
* (cel-js's 33, plus our `can`)
*
* 39 bare-callable
* = 27 added by `registerStdLib` -> ALL advertised (one per registration site)
* 41 bare-callable
* = 29 added by `registerStdLib` -> ALL advertised (one per registration site)
* + 8 cel-js built-ins -> advertised: has size int string bool double
* timestamp duration
* + 4 cel-js built-ins WITHHELD -> bytes dyn type uint
Expand All @@ -1144,13 +1145,14 @@ export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint):
* cel-js built-in cannot arrive unnoticed.
*
* ⛔ This list is NOT an oracle for rejecting unknown functions. A gate that
* rejects what is absent here would reject 37 names that resolve and evaluate
* rejects what is absent here would reject 38 names that resolve and evaluate
* today. The unknown-function verdict belongs to the engine's own `check()`
* (ruling on #13594); `@objectstack/lint` uses that and never reads this list.
*/
export const CEL_STDLIB_FUNCTIONS: string[] = [
// Dates (registered stdlib)
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime',
'isoDate', 'isoDatetime',
// Numbers (registered stdlib)
'abs', 'round', 'floor', 'ceil', 'min', 'max',
// Strings (registered stdlib)
Expand Down
Loading
Loading