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
15 changes: 15 additions & 0 deletions .changeset/fix-1530-date-formula-cel.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
});
61 changes: 41 additions & 20 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <op> int` (and kin) when a comparison
* or arithmetic operator sees a `string` on one side and a number on the
Expand All @@ -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<string, unknown> = {};
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;
Expand Down Expand Up @@ -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<string, unknown>;
const hydrated = hydrateOverloadStrings(scope) as Record<string, unknown>;
try {
const raw = env.evaluate(source, hydrated);
return { ok: true, value: coerce(raw) as T };
Expand Down