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
17 changes: 17 additions & 0 deletions .changeset/cel-stdlib-catalog-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/formula": minor
---

feat(formula): register the CEL functions the authoring catalog advertises (daysBetween, abs, round, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty, date, datetime)

`introspectScope` / `CEL_STDLIB_FUNCTIONS` advertised 25 functions to authors
(incl. AI), but only 8 were registered — 14 faulted at runtime (`daysBetween`,
`abs`, `round`, `min`, `max`, `upper`, `lower`, `len`, `isEmpty`, `contains`,
`startsWith`, `endsWith`, `matches`, plus `date`/`datetime`). Authors were told
to call functions that don't exist (e.g. `daysBetween` for "days remaining").

Register the genuinely-useful set in `registerStdLib` with dyn-lenient signatures
(so a `Field.date` arriving as a string still works) and internal coercion, and
reconcile the catalog so every advertised entry resolves — guarded by a test that
evaluates every `CEL_STDLIB_FUNCTIONS` entry. Pure additions; no behavior change
to existing expressions.
6 changes: 6 additions & 0 deletions examples/app-crm/src/objects/opportunity.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const Opportunity = ObjectSchema.create({
close_date: Field.date({
label: 'Close Date',
}),
// Exercises the newly-registered `daysBetween` stdlib function — the canonical
// "days remaining" formula (negative once the close date has passed).
days_to_close: Field.formula({
label: 'Days to Close',
expression: cel`record.close_date == null ? 0 : daysBetween(today(), record.close_date)`,
}),
discount_percent: Field.percent({
label: 'Discount %',
defaultValue: 0,
Expand Down
74 changes: 74 additions & 0 deletions packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import { celEngine } from './cel-engine';
import { CEL_STDLIB_FUNCTIONS } from './validate';
import type { Expression } from '@objectstack/spec';

const cel = (source: string): Expression => ({ dialect: 'cel', source });
Expand Down Expand Up @@ -296,4 +297,77 @@ describe('celEngine', () => {
expect(r).toEqual({ ok: true, value: true });
});
});

// #1928 follow-up — the authoring catalog (`introspectScope`) advertised 25
// functions but only 8 were registered; 14 faulted at runtime. These cover the
// newly-registered stdlib, plus a drift-guard that every advertised function resolves.
describe('stdlib catalog (registered functions)', () => {
const now = new Date('2026-06-16T00:00:00Z');

it('daysBetween counts whole days (negative when reversed)', () => {
expect(celEngine.evaluate(cel('daysBetween(today(), daysFromNow(7))'), { now }))
.toEqual({ ok: true, value: 7 });
expect(celEngine.evaluate(cel('daysBetween(today(), daysAgo(3))'), { now }))
.toEqual({ ok: true, value: -3 });
});

it('daysBetween coerces a date-string field arg (no manual hydration)', () => {
const r = celEngine.evaluate(cel('daysBetween(today(), record.due)'), {
now, record: { due: '2026-06-26' },
});
expect(r).toEqual({ ok: true, value: 10 });
});

it('date / datetime parse ISO strings to a timestamp', () => {
const r = celEngine.evaluate(cel('date("2026-03-15") < datetime("2026-03-16T08:00:00Z")'), {});
expect(r).toEqual({ ok: true, value: true });
});

it('abs / round / min / max', () => {
expect(celEngine.evaluate(cel('abs(record.x)'), { record: { x: -3.5 } })).toEqual({ ok: true, value: 3.5 });
expect(celEngine.evaluate(cel('round(2.6)'), {})).toEqual({ ok: true, value: 3 });
expect(celEngine.evaluate(cel('min(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 3 });
expect(celEngine.evaluate(cel('max(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 7 });
});

it('string ops: upper / lower / contains / startsWith / endsWith / matches', () => {
expect(celEngine.evaluate(cel('upper(record.s)'), { record: { s: 'hi' } })).toEqual({ ok: true, value: 'HI' });
expect(celEngine.evaluate(cel('lower("HI")'), {})).toEqual({ ok: true, value: 'hi' });
expect(celEngine.evaluate(cel('contains(record.s, "ell")'), { record: { s: 'hello' } })).toEqual({ ok: true, value: true });
expect(celEngine.evaluate(cel('startsWith("hello", "he")'), {})).toEqual({ ok: true, value: true });
expect(celEngine.evaluate(cel('endsWith("hello", "lo")'), {})).toEqual({ ok: true, value: true });
expect(celEngine.evaluate(cel('matches("a1", "a.")'), {})).toEqual({ ok: true, value: true });
});

it('len / isEmpty over strings and lists', () => {
expect(celEngine.evaluate(cel('len(record.items)'), { record: { items: [1, 2, 3] } })).toEqual({ ok: true, value: 3 });
expect(celEngine.evaluate(cel('isEmpty("")'), {})).toEqual({ ok: true, value: true });
expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [] } })).toEqual({ ok: true, value: true });
expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [1] } })).toEqual({ ok: true, value: false });
});

// Drift guard: introspectScope promises these to authors; every one must resolve.
it('every advertised CEL_STDLIB_FUNCTIONS entry resolves at runtime', () => {
const call: Record<string, string> = {
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
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")',
endsWith: 'endsWith("hi", "i")', matches: 'matches("a1", "a.")', joinNonEmpty: 'joinNonEmpty(["a", "b"], "-")',
isBlank: 'isBlank("")', isEmpty: 'isEmpty([])', coalesce: 'coalesce(null, "x")', len: 'len("ab")',
size: 'size([1, 2])', has: 'has(record.a)', int: 'int("3")', string: 'string(3)',
bool: 'bool("true")', double: 'double("3.5")', timestamp: 'timestamp("2026-01-01T00:00:00Z")',
duration: 'duration("3600s")',
};
const unresolved: string[] = [];
for (const fn of CEL_STDLIB_FUNCTIONS) {
const src = call[fn];
expect(src, `no probe call defined for advertised function \`${fn}\``).toBeTruthy();
const r = celEngine.evaluate(cel(src), { now, record: { a: 1 } });
if (!r.ok) unresolved.push(`${fn}: ${r.error.message.split('\n')[0]}`);
}
expect(unresolved, `advertised functions that fault at runtime:\n${unresolved.join('\n')}`).toEqual([]);
});
});
});
59 changes: 59 additions & 0 deletions packages/formula/src/stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ function startOfDayUtc(d: Date): Date {
return out;
}

/** Coerce a CEL value (Date | ISO string | epoch number) to a Date. */
function toDate(v: unknown): Date {
if (v instanceof Date) return v;
if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v));
return new Date(String(v));
}

/** One UTC day in milliseconds. */
const MS_PER_DAY = 86_400_000;

/** 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 @@ -101,9 +111,58 @@ export function registerStdLib(
}
return parts.join(separator);
},
)
// ── Dates ────────────────────────────────────────────────────────────
// Whole days from `a` to `b` (negative if `b` is before `a`). The common
// shape is `daysBetween(today(), record.due)` for "days remaining". Args are
// coerced (Date | ISO string | epoch) so a `Field.date` that arrives as a
// string still works without the caller hydrating it.
.registerFunction(
'daysBetween(dyn, dyn): int',
(a: unknown, b: unknown) =>
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
)
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
// are aliases — both accept either form (the field's own type decides the
// 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))
// ── Numbers ──────────────────────────────────────────────────────────
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))
// min/max return the smaller/larger operand verbatim (type preserved) rather
// than a coerced copy, so `min(record.a, record.b)` keeps int-ness when both
// are ints. Comparison is numeric.
.registerFunction('min(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) <= Number(b) ? a : b))
.registerFunction('max(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) >= Number(b) ? a : b))
// ── Strings ──────────────────────────────────────────────────────────
// Free-function forms of the common string ops. CEL also exposes some as
// receiver methods (`s.contains(x)`), but the authoring catalog advertises
// the bare-call form, so register it to match what authors are told to use.
.registerFunction('upper(dyn): string', (s: unknown) => String(s ?? '').toUpperCase())
.registerFunction('lower(dyn): string', (s: unknown) => String(s ?? '').toLowerCase())
.registerFunction('contains(dyn, dyn): bool', (s: unknown, sub: unknown) => String(s ?? '').includes(String(sub ?? '')))
.registerFunction('startsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').startsWith(String(p ?? '')))
.registerFunction('endsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').endsWith(String(p ?? '')))
.registerFunction('matches(dyn, dyn): bool', (s: unknown, re: unknown) => new RegExp(String(re ?? '')).test(String(s ?? '')))
// ── Collections ──────────────────────────────────────────────────────
// `len` mirrors CEL's built-in `size()` for strings/lists/maps; `isEmpty` is
// the inverse-of-non-empty companion to `isBlank` (true for null, '', []).
.registerFunction('len(dyn): int', (v: unknown) => BigInt(lengthOf(v)))
.registerFunction(
'isEmpty(dyn): bool',
(v: unknown) => v === null || v === undefined || lengthOf(v) === 0,
);
}

/** Length of a string / list / map (0 for scalars and null). */
function lengthOf(v: unknown): number {
if (v === null || v === undefined) return 0;
if (typeof v === 'string' || Array.isArray(v)) return v.length;
if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length;
return 0;
}

/**
* Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
*
Expand Down
21 changes: 16 additions & 5 deletions packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,21 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
};
}

/** Public catalog of CEL stdlib functions available in expressions. */
/**
* Public catalog of CEL functions available in expressions — what `introspectScope`
* advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:
* either registered in `registerStdLib` or a verified cel-js built-in. Drifting this
* list ahead of the runtime tells the author to call functions that fault (#1928).
*/
export const CEL_STDLIB_FUNCTIONS: string[] = [
'now', 'today', 'daysFromNow', 'daysBetween', 'date', 'datetime', 'timestamp',
'isBlank', 'isEmpty', 'coalesce', 'len', 'size', 'int', 'float', 'string', 'bool',
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches',
'has', 'min', 'max', 'abs', 'round',
// Dates (registered stdlib)
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime',
// Numbers (registered stdlib)
'abs', 'round', 'min', 'max',
// Strings (registered stdlib)
'upper', 'lower', 'trim', 'contains', 'startsWith', 'endsWith', 'matches', 'joinNonEmpty',
// Collections / null-ish (registered stdlib)
'isBlank', 'isEmpty', 'coalesce', 'len',
// cel-js built-ins (verified to resolve)
'size', 'has', 'int', 'string', 'bool', 'double', 'timestamp', 'duration',
];
Loading