diff --git a/.changeset/22277-cel-timestamp-string.md b/.changeset/22277-cel-timestamp-string.md new file mode 100644 index 0000000000..59b4db5adc --- /dev/null +++ b/.changeset/22277-cel-timestamp-string.md @@ -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. diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 2a7ad45b19..d4ffbc7638 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -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 | diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index f74b990df6..8bf1890462 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -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")', diff --git a/packages/formula/src/stdlib-timestamp-text.test.ts b/packages/formula/src/stdlib-timestamp-text.test.ts new file mode 100644 index 0000000000..b84871f513 --- /dev/null +++ b/packages/formula/src/stdlib-timestamp-text.test.ts @@ -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); + } + }); +}); diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index d7d2c4d8e3..ecf451d407 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -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()); @@ -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)))) diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 2e753a6e8a..a789392bbb 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -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 @@ -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) diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 5852c9cbb4..14ab3dfba3 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -146,6 +146,7 @@ tests: every entry resolves at runtime, and this table documents them all. | `addDays(d, n)` | timestamp | Shift **any** date by `n` days (negative ok). `addDays(record.last_service, record.cycle_days)` = next due date | | `addMonths(d, n)` | timestamp | Shift **any** date by `n` months; clamps to month-end (`addMonths(date('2026-01-31'), 1)` → Feb 28) | | `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string to a timestamp | +| `isoDate(t)` / `isoDatetime(t)` | string | Timestamp to UTC ISO text, `2026-10-08` / `2026-10-08T17:55:06.123Z`. Text in? `isoDate(date(s))` | > **No date arithmetic.** A date mixed with a number faults and the build > rejects it; `end - start` does not fault — it yields a `duration` stored as