From 6516d7845c0d3d6f74d33120bfa62f3db547d071 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:15:32 +0800 Subject: [PATCH 1/2] fix(formula): hydrate ISO date/datetime strings on CEL overload fault (#1530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date-typed formula fields (and any predicate comparing a date field to a temporal builtin) always evaluated to null. `Field.date` serializes to "YYYY-MM-DD" and `Field.datetime` to a full ISO string; cel-js compares that raw string against the `google.protobuf.Timestamp` returned by today() / now() / daysFromNow() and raises `no such overload`, which surfaced as a silent null (ADR-0032 §1c). This is the date analogue of the numeric-string fix (#1534), so it reuses the exact same fault-retry path in cel-engine: on a `no such overload`, hydrate the offending scope and retry ONCE. Extend that hydration to also coerce strict ISO-8601 date / date-time strings to `Date`, alongside the existing numeric-string → number coercion. Renamed `hydrateNumericStrings` → `hydrateOverloadStrings` to reflect both. Fixing it in the engine (not in objectql's applyFormulaPlan as the issue hypothesized) repairs every caller — formula fields, flow conditions, validation/workflow predicates — not just formula projection. Hydration runs only after a fault, so expressions that already type-check are never re-interpreted, and a genuine non-temporal string still faults loudly. Tests: 8 new cases in cel-engine.test.ts (is_expiring_soon date-only window, unmet compare → false, is_overdue, full ISO datetime vs now(), timestamp arithmetic today() - hire_date, mixed date+numeric record, non-temporal string still faults, no spurious coercion on string==string). Full formula suite green (90/90). Closes #1530. Co-Authored-By: Claude Opus 4.8 --- packages/formula/src/cel-engine.test.ts | 75 +++++++++++++++++++++++++ packages/formula/src/cel-engine.ts | 61 +++++++++++++------- 2 files changed, 116 insertions(+), 20 deletions(-) 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 }; From f01f9fa7a49ca7fade1ee19cbfdf3c95701ced27 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:17:03 +0800 Subject: [PATCH 2/2] chore: add changeset for #1530 formula date fix --- .changeset/fix-1530-date-formula-cel.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/fix-1530-date-formula-cel.md 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.