diff --git a/.changeset/fix-1530-date-formula-cel.md b/.changeset/fix-1530-date-formula-cel.md new file mode 100644 index 000000000..998f56f41 --- /dev/null +++ b/.changeset/fix-1530-date-formula-cel.md @@ -0,0 +1,15 @@ +--- +"@objectstack/formula": patch +--- + +fix(formula): hydrate ISO date/datetime strings on CEL `no such overload` fault (#1530) + +Date-typed formula fields and date predicates always evaluated to `null`: +`Field.date`/`Field.datetime` serialize to ISO strings, and cel-js compared the +raw string against the `google.protobuf.Timestamp` from `today()`/`now()`/ +`daysFromNow()`, raising `no such overload` (swallowed to null). The existing +numeric-string fault-retry (#1534) is now extended to also coerce strict ISO-8601 +date/date-time strings to `Date` before retrying once, fixing every caller +(formula fields, flow conditions, validation/workflow predicates). Hydration runs +only after a fault, so clean expressions are never re-interpreted and genuine +non-temporal strings still fault loudly. diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index 2180a750b..6a3776aed 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -147,4 +147,79 @@ describe('celEngine', () => { expect(r.ok).toBe(false); }); }); + + // ADR-0032 §1c — string-serialized date/datetime fields (#1530). Field.date + // serializes to "YYYY-MM-DD" and Field.datetime to a full ISO string; cel-js + // compares those raw strings against the google.protobuf.Timestamp returned by + // today()/now()/daysFromNow() and faults `no such overload`, which previously + // surfaced as a silent `null`. + describe('date/datetime-string field hydration (#1530)', () => { + const now = new Date('2026-06-02T08:00:00Z'); + + it('compares a date-only field against today()/daysFromNow() (is_expiring_soon)', () => { + const r = celEngine.evaluate( + cel('record.end_date >= today() && record.end_date <= daysFromNow(60)'), + { now, record: { end_date: '2026-06-20' } }, + ); + expect(r).toEqual({ ok: true, value: true }); + }); + + it('returns false (not a fault) when the date compare is unmet', () => { + const r = celEngine.evaluate(cel('record.end_date <= daysFromNow(60)'), { + now, + record: { end_date: '2027-01-01' }, + }); + expect(r).toEqual({ ok: true, value: false }); + }); + + it('handles is_overdue: a past date-only field < today()', () => { + const r = celEngine.evaluate(cel('record.due_date < today()'), { + now, + record: { due_date: '2026-05-31' }, + }); + expect(r).toEqual({ ok: true, value: true }); + }); + + it('hydrates a full ISO datetime field against now()', () => { + const r = celEngine.evaluate(cel('record.resolution_due_at < now()'), { + now, + record: { resolution_due_at: '2026-06-01T08:15:35.244Z' }, + }); + expect(r).toEqual({ ok: true, value: true }); + }); + + it('supports timestamp arithmetic on hydrated date fields (today() - hire_date)', () => { + // hire_date ~2.4y before `now` → tenure exceeds 2 years (17520h). + const r = celEngine.evaluate( + cel('(today() - record.hire_date) > duration("17520h")'), + { now, record: { hire_date: '2024-01-01' } }, + ); + expect(r).toEqual({ ok: true, value: true }); + }); + + it('hydrates date + numeric strings together in one record', () => { + const r = celEngine.evaluate( + cel('record.amount >= 1000 && record.end_date >= today()'), + { now, record: { amount: '2500.00', end_date: '2026-06-20' } }, + ); + expect(r).toEqual({ ok: true, value: true }); + }); + + it('does not coerce non-temporal strings (still faults loudly)', () => { + const r = celEngine.evaluate(cel('record.end_date <= today()'), { + now, + record: { end_date: 'soon' }, + }); + expect(r.ok).toBe(false); + }); + + it('leaves genuine date-string equality untouched (no spurious coercion)', () => { + // string == string type-checks, so the retry never runs and the value + // stays a string. + const r = celEngine.evaluate(cel('record.end_date == "2026-06-20"'), { + record: { end_date: '2026-06-20' }, + }); + expect(r).toEqual({ ok: true, value: true }); + }); + }); }); diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 9081357fa..e52981b1d 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -71,6 +71,17 @@ function coerce(value: unknown): unknown { // digit runs (CodeQL ReDoS). This matches the same strings without the hazard. const NUMERIC_STRING_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; +/** + * A string that is an ISO-8601 date (`"2026-06-20"`) or date-time + * (`"2026-06-20T08:15:35.244Z"`, `"2026-06-20 08:15"`, `"...+02:00"`). Strict + * and anchored — no nested unbounded quantifiers, so no ReDoS hazard (every + * sub-group is bounded or a single `\.\d+`). `Field.date` / `Field.datetime` + * serialize to these; cel-js compares them as `string` and faults against the + * `google.protobuf.Timestamp` returned by `today()` / `now()` / `daysFromNow()`. + */ +const ISO_TEMPORAL_STRING_RE = + /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/; + /** * cel-js raises `no such overload: dyn int` (and kin) when a comparison * or arithmetic operator sees a `string` on one side and a number on the @@ -85,27 +96,33 @@ function isNumericOverloadError(err: unknown): boolean { } /** - * Recursively coerce string values that are *entirely* numeric literals into - * numbers. Used only on the {@link isNumericOverloadError} retry path, so it - * can never change a comparison that already evaluated cleanly — it only - * rescues one that already faulted. Dates and non-numeric strings pass through - * untouched (a zip like `"02134"` only changes if the surrounding expression - * already faulted, in which case the original loud error is preserved when the - * retry still cannot type-check). + * Recursively coerce string values that faulted a CEL overload into their + * intended primitive: entirely-numeric literals → `number` (#1534), and + * ISO-8601 date / date-time strings → `Date` (cel-js `google.protobuf.Timestamp`) + * (#1530). Used only on the {@link isNumericOverloadError} retry path, so it can + * never change a comparison that already evaluated cleanly — it only rescues one + * that already faulted. Strings that are neither (a zip like `"02134"`, free + * text) pass through untouched; if the retry still cannot type-check, the + * original loud error is preserved. */ -function hydrateNumericStrings(value: unknown): unknown { +function hydrateOverloadStrings(value: unknown): unknown { if (typeof value === 'string') { const trimmed = value.trim(); - if (trimmed.length > 0 && NUMERIC_STRING_RE.test(trimmed)) { - const n = Number(trimmed); - if (Number.isFinite(n)) return n; + if (trimmed.length > 0) { + if (NUMERIC_STRING_RE.test(trimmed)) { + const n = Number(trimmed); + if (Number.isFinite(n)) return n; + } else if (ISO_TEMPORAL_STRING_RE.test(trimmed)) { + const ms = Date.parse(trimmed); + if (!Number.isNaN(ms)) return new Date(ms); + } } return value; } - if (Array.isArray(value)) return value.map(hydrateNumericStrings); + if (Array.isArray(value)) return value.map(hydrateOverloadStrings); if (value && typeof value === 'object' && !(value instanceof Date)) { const out: Record = {}; - for (const [k, v] of Object.entries(value)) out[k] = hydrateNumericStrings(v); + for (const [k, v] of Object.entries(value)) out[k] = hydrateOverloadStrings(v); return out; } return value; @@ -170,14 +187,18 @@ export const celEngine: DialectEngine = { const raw = env.evaluate(source, scope); return { ok: true, value: coerce(raw) as T }; } catch (err) { - // ADR-0032 §1c — string-serialized numeric fields (`rating` → `"5.0"`, - // `amount` → `"250000.00"`) make `record.rating >= 4` raise CEL's - // `no such overload: dyn >= int`. Hydrate purely-numeric strings to - // numbers and retry ONCE. This only runs after a fault, so a comparison - // that already evaluated cleanly is never re-interpreted; if the retry - // still cannot type-check, the original loud error is reported (#1534). + // ADR-0032 §1c — string-serialized fields make CEL raise + // `no such overload`: numeric fields (`rating` → `"5.0"`, + // `amount` → `"250000.00"`) on `record.rating >= 4` (#1534), and + // date/datetime fields (`end_date` → `"2026-06-20"`) on + // `record.end_date <= daysFromNow(60)` (#1530), since cel-js compares the + // raw string against the `google.protobuf.Timestamp` from `today()` etc. + // Hydrate those strings to number / Date and retry ONCE. This only runs + // after a fault, so a comparison that already evaluated cleanly is never + // re-interpreted; if the retry still cannot type-check, the original loud + // error is reported. if (!isNumericOverloadError(err)) throw err; - const hydrated = hydrateNumericStrings(scope) as Record; + const hydrated = hydrateOverloadStrings(scope) as Record; try { const raw = env.evaluate(source, hydrated); return { ok: true, value: coerce(raw) as T };