Skip to content

Latest commit

 

History

History
709 lines (593 loc) · 31.8 KB

formulas.mdx

File metadata and controls

709 lines (593 loc) · 31.8 KB
title Formulas — CEL expressions on records
navTitle Expressions (CEL)
description Canonical CEL-based expression language for formulas, predicates, conditions, and dynamic seed values

Expressions

ObjectStack uses a single canonical expression language for every place a piece of metadata needs to compute a value or evaluate a condition:

  • Formula fields (type: 'formula')
  • Predicates — validation condition, sharing condition, field conditional rules (visibleWhen, readonlyWhen, requiredWhen), view section/column visibility (visibleWhen), action disabled, hook condition, flow decisions
  • Dynamic seed values — fixtures whose value depends on the install-time clock or identity context

The language is CEL (Google's Common Expression Language, Apache-2.0), evaluated through @objectstack/formula (thin wrapper over @marcbachmann/cel-js) plus the ObjectStack standard library.

Why CEL? Formal grammar, abundant public training corpus (so AI authors emit it natively), AST-first persistence, and sandboxed execution with bounded cost. ObjectStack does not ship a Salesforce-flavor DSL — the previous custom 22-function engine was deleted in M9. See the architecture overview principles on typed metadata over custom DSLs.


The Expression envelope

Every expression in metadata is persisted as the same envelope:

{/* os:check */}

type Expression = {
  dialect: 'cel' | 'cron' | 'template';
  source?: string;          // surface syntax
  ast?: unknown;            // parsed AST (filled by `os compile`)
  meta?: { rationale?: string; generatedBy?: string };
};

ObjectStack ships three registered expression dialects:

Dialect Use for Helper
cel computed values, predicates, defaults, formulas cel`...` / F`...` / P`...`
cron recurring schedules (jobs, scheduled flows, exports, connector sync, backups) cron`...`
template text interpolation ({{path}}) — notif/prompt/title tmpl`...`

The js expression dialect was retired in v16 (#3278, ADR-0058 addendum) — it was a stub with no engine. Procedural JavaScript is unaffected: it remains the L2 authoring surface as a sandboxed ScriptBody { language: 'js' } in hook/action bodies (a separate enum), not an expression dialect.

All dialects share the same variable scope (record, previous, os.user, os.org, os.env, vars for flow steps), so you author once and never have to relearn the syntax across surfaces.

**Predicates are bare CEL — never wrap field references in `{…}` braces.** The most common mistake (and the root cause of issue #1491) is a condition like `{record.rating} >= 4`: in CEL, `{…}` is a **map literal**, so it is a parse error. Write bare CEL: `record.rating >= 4`. Braces belong only in `{{ … }}` text templates. A malformed expression does not silently do nothing — it fails `os build` and throws at runtime.

Template formatting. A template hole is a field path with an optional whitelisted formatter — {{ path | formatter[:arg] }} — with defined value→string semantics (no arbitrary logic; put logic in a CEL field):

tmpl`Deal {{record.name}} — {{record.amount | currency}} closes {{record.close_date | date:long}}`

Formatters: currency[:CODE], number[:decimals], percent[:decimals], date[:short|long|iso], datetime[:…], upper, lower, trim, truncate:N, default:'…', json. Single-brace {x} is not a valid template hole. currency with no code formats in the organization's default currency (the localization.currency setting, the one currency fields and dashboards read); when the organization has none, the render refuses the hole by name instead of guessing a currency. Write currency:EUR where the amount's currency is fixed. For a record-mode amount, whose currency is each record's own code, a mail template writes currency:@currency_code: @ and a path into the send's data whose value is the code. Only mail templates admit the @ form; the in-app dialect refuses it by name.

At input time you may write a bare string for shorthand — the spec transforms it into the right envelope based on the field type. The compiled artifact always contains the full envelope: source is the canonical persisted form, and ast is an optional opaque structured value.

{/* os:check */}

import { F, P, cel, cron, tmpl } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Invoice = ObjectSchema.create({
  name: 'invoice',
  sharingModel: 'private',
  nameField: 'display_title', // ADR-0079 — the record title is a designated field; a composite title migrates off the deprecated `titleFormat` to a text formula for display only, or to a stored computed field (a non-formula field with an `expression`, written on write) when import, sort or search must match it
  fields: {
    // Composite record title as a text formula, surfaced via `nameField` above.
    display_title: Field.formula({
      returnType: 'text',
      expression: F`"Invoice " + string(record.invoice_no) + " – " + record.customer.name`,
    }),
    total: Field.formula({
      returnType: 'number',
      expression: F`record.subtotal + record.subtotal * record.tax_rate`,
    }),
    po_number: Field.text({
      requiredWhen: P`record.amount > 10000`,
    }),
    // M9.9b — declarative default, evaluated at insert time
    issued_date: Field.date({
      defaultValue: cel`today()`,
    }),
  },
});
**A formula field's `type` is always `formula`** — that is the key the runtime evaluates (`objectql` computes a formula virtual field from its `expression` only). Do **not** pass `type: 'currency' | 'number' | …` to `Field.formula` — it is rejected by the typed `FieldInput` and, untyped, would override `type:'formula'` so the field silently never computes. To declare what the formula returns, set **`returnType`** (`'number' | 'text' | 'boolean' | 'date' | 'currency'`). You declare it yourself — nothing back-fills it — but the agent-callable `validate_expression` tool reports the type it infers from the expression (`inferredType`) so an AI author can stamp the matching value. A `'currency'` result is an amount of money and carries its currency the way a currency field does: in the formula's own `currencyConfig` (none means dynamic, the tenant default currency; over record-mode amounts it is `{ currencyMode: 'record', currencyField }`, naming the same code field). Copy it from the currency field the formula computes from — `inferFormulaReturn` in `@objectstack/formula` answers both halves for a formula it can prove is money. Consumers read the declared `returnType` instead of re-parsing the expression (record-title eligibility, for one: a formula is title-eligible only when its `returnType` is `'text'`, which is what lets it be *derived* as the record title — an explicit `nameField` pointer is honored either way). The CEL source is the **`expression`** key — it is the only key the schema and runtime read for a formula's source; there is no separate `formula` source key.

The three CEL tagged-template helpers — cel, F (formula alias), P (predicate alias) — are interchangeable; pick whichever reads best at the call site. cron and tmpl produce their respective envelopes.


CEL primer

CEL is documented at cel-spec. Cheat-sheet of what you'll write daily:

Concept Syntax
Field on current record record.first_name
Previous (in update hooks) previous.status
Input to a hook input.amount
Equality / inequality == / !=
Logical && / || / !
Ternary cond ? then : else
String literal 'single quotes'
Membership record.region in ['us', 'eu']
Field present has(record.foo) (returns true even when value is null)

has() gotcha: has(record.x) is true whenever the key exists, even when its value is null. To check for "not blank" use isBlank(...) or record.x != null — see stdlib below.


ObjectStack CEL standard library

Registered automatically into every Environment by @objectstack/formula. All functions are pure given a pinned now, which is what makes os build artifacts byte-stable across runs.

Function Returns Description
now() timestamp Pinned wall-clock at evaluation start
today() timestamp Calendar day in the business timezone, as UTC midnight
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
coalesce(v, fallback) dyn v when non-null, else fallback
trim(v) string v with leading/trailing whitespace removed
joinNonEmpty(list, sep) string list joined by sep, skipping blank entries
upper(s) / lower(s) string Case conversion
contains(s, sub) / startsWith(s, p) / endsWith(s, p) bool Substring checks (free-function form)
matches(s, regex) bool Regex test
len(v) int Length of a string / list / map (mirrors CEL's built-in size())
abs(x) / round(x) number Numeric helpers
floor(x) / ceil(x) number Round toward −∞ / +∞ (floor(-1.2) == -2) — not integer-division's round-toward-zero
min(a, b) / max(a, b) dyn Smaller / larger operand (numeric comparison)
label(record.f) string A select field's option label in the reader's locale — see Record name from an option label

Add new helpers in packages/formula/src/stdlib.ts. Keep them pure, dependency-free, and AI-readable.

Variable scope

@objectstack/formula builds the scope per evaluation:

Binding Source Available in
record the row being evaluated formulas, validation, sharing, visibility
previous the row before the update — on a multi: true write, that row's own pre-write state, in before* and after* hooks alike and in record-change triggers: since ADR-0058 Addendum II both phases dispatch once per matched row. null on insert, so previous == null judges an insert; a column read off it there (previous.status) has no value and faults validation, hook conditions and flows — null on insert
input hook payload hooks
current_user the authenticated subject — the canonical binding (ADR-0068). user, ctx.user and os.user are aliases of the same object predicates with identity
os.user alias of current_user seed, predicates with identity
os.org active organization seed, predicates
os.env install env (prod, dev, test) seed, predicates

Build-time validation

os build type-checks every expression against the object schema, so a mistake that would silently evaluate to null at runtime is caught before it ships. The same shared validator also runs when a flow is registered (registerFlow), so a flow authored dynamically gets the same verdict. Findings come at two severities: errors fail the build; warnings are advisory (surfaced, not fatal — os validate --strict promotes them to errors).

Check Example Severity
Qualify field references — a formula / validation / predicate binds only the record namespace, so a bare amount resolves to nothing and evaluates to null. Write record.amount. amount > 100 → record.amount > 100 error
Field must exist — a typo'd record.<field> is flagged with a did-you-mean. record.amont → record.amount error
Unknown function — a misspelled or nonexistent stdlib call. isBlnk(record.x) error
Type soundness — a text or boolean field used with an arithmetic (+ - * / %) or ordering (< > <= >=) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to null. Where a fault does something else it is an error, worded for what happens: a validation rule, a field requiredWhen / readonlyWhen, an option visibleWhen or a hook condition refuses the write; a record-triggered flow's start, edge or decision branch condition fails the run (the write that fired it still lands), whether it reads the field bare or as record.<field>, and so does a time-relative flow's that sweeps the same object; a bare name that a declared flow variable or a screen field holds reads that value, so it is not judged as the field, and a bare name after a node that can bind any name (script, connector_action, wait, subflow, map, a screen with no fields) gets the warning instead, while its record.<field> read is still refused; an advisory (severity: 'warning' / 'info') validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (size(record.name) for the length; double(record.name), which itself faults on text that is not a number). record.title * 2; record.name > 5 in a validation rule → size(record.name) > 5 warning in a formula or a display predicate, and in a condition of any other flow, where the warning says the run fails if the condition faults (what such a flow binds under the name depends on how it is started); error at the sites named
Date arithmetic — a date / datetime field used with + / - against a number (a YYYY-MM-DD value is a string at runtime, so this always faulted to null). Use the date helpers instead. record.end_date - record.start_date + 1 → daysBetween(record.start_date, record.end_date) + 1; today() + 30 → daysFromNow(30); record.date + n → addDays(record.date, n) error (#3306)
Date subtraction — the runtime holds a date / datetime field as its text (YYYY-MM-DD, or an ISO-8601 instant), so subtracting two of them faults and the formula reads null; a timestamp minus one computes a duration, which a field reads as {}. Use daysBetween. A condition may still compare a duration (today() - record.start_date > duration("72h")). record.end_date - record.start_date → daysBetween(record.start_date, record.end_date); today() - record.start_date → daysBetween(record.start_date, today()) error

Integer literals in arithmetic are fine. record.amount / 100, record.price * 2, record.total - fee all work — the engine handles mixed double × int arithmetic, so you never need the 100.0 / 2.0 float-literal workaround. Equality against any type is also safe (record.stage == 5 simply returns false), so only arithmetic/ordering on a genuinely text/boolean field is flagged.

In flow and automation conditions the record's fields are bound bare (stage == "won", not record.stage), so a bare reference is correct there. Instead, a bare identifier that is a near-miss of a real field gets a did-you-mean warning (a genuine flow variable is left alone), and the same type-soundness check applies.


Cookbook

Computed full name (handle nullable parts)

{
  name: 'full_name',
  type: 'formula',
  expression: F`joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')`,
}

joinNonEmpty skips blank parts, so you avoid the verbose triple-coalesce. Building the string by hand with + would also work, but CEL throws on null + string, so each nullable operand must be wrapped in coalesce(..., '').

Record name from an option label

A select field stores its option's value (clean), not its label. To put the label a reader reads into a composed name, wrap the field in label():

{
  name: 'display_name',            // the object's nameField
  type: 'formula',
  expression: F`'v' + string(record.version_no) + ' · ' + label(record.kind)`,
}

The same record reads v2 · 清稿 to a reader whose locale is zh-CN and v2 · Clean to an en-US reader. The label is the one the select control shows that reader: the field's declared option label, with the object's translation bundle laid over it (objects.<object>.fields.<field>.options.<value>, then a shared picklist's picklists.<picklist>.options.<value>), exactly as GET /meta/object/:name serves it. The locale is the request's Accept-Language, so the write response the save toast reads, the record page title, related lists and lookups all follow it.

  • label() takes exactly record.<field>, a field that declares options or names a picklist. Any other field is refused when the object is saved (os validate, os build, the object save door), with the field named, because it has no label to read and the formula would read null on every row. An empty value reads ''; a stored value no option declares reads as itself. A multi-value select is refused.
  • A read with no locale (a script, a scheduled job) reads the declared labels.
  • A stored computed field (a text field with an expression) is computed once, on the write, so it stores the declared label whatever the writer's locale. Search never indexes a formula field, so it is unaffected.
  • label() answers only where the engine serves formula fields. A validation rule, predicate or flow condition that calls it fails loudly instead of comparing against a label; compare the stored value there (record.kind == 'clean').

Conditional formula

{
  name: 'roi',
  type: 'formula',
  expression: F`coalesce(record.actual_cost, 0) > 0
             ? ((coalesce(record.actual_revenue, 0) - record.actual_cost) * 100.0) / record.actual_cost
             : 0.0`,
}

Predicate (visibility, read-only, conditional required)

A conditional-rule predicate binds whatever its layer binds, and visibleWhen is one key spelled on layers that do not share a scope — object field, per-option, form-view field, page component, flow screen. So write the enclosing structure: the fields: map below is what says object field, the layer on which a predicate binds record (plus previous, and parent on a master-detail line item) and nothing else. A bare { name, type, visibleWhen } fragment names no layer, so neither a reader nor os build can tell which scope it is written against.

ObjectSchema.create({
  name: 'opportunity',
  fields: {
    // Raw (non-factory) spelling — the map key is the field name.
    rating: {
      type: 'select',
      options: [{ label: 'Hot', value: 'hot' }, { label: 'Cold', value: 'cold' }],
      visibleWhen: P`record.status == 'qualified'`,
    },
  },
})

Field.text({
  visibleWhen: P`record.type == 'business'`,
  readonlyWhen: P`record.status == 'approved'`,
  requiredWhen: P`record.amount > 10000`,
})

Dynamic seed dates

Use CEL inside seed records to defer evaluation to install time. The SeedLoader pins a single now per load run, so the same dataset produces identical SHA-1 across builds while still being "fresh" relative to whoever installs the package.

import { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';
import { Opportunity } from './objects/opportunity.object';

export const opportunitySeed = defineSeed(Opportunity, {
  records: [
    {
      name: 'Acme Q3 Renewal',
      close_date: cel`daysFromNow(45)`,
      created_at: cel`now()`,
    },
  ],
});

Without CEL, new Date() would be evaluated at compile time — your package would ship with the timestamp from your laptop and every customer would see "stale" demo data. With CEL, the date resolves on the customer's clock at install time.

Validation rule

{
  name: 'amount_positive',
  type: 'script',
  condition: P`record.amount <= 0`,
  message: 'Amount must be positive.',
}

The generic CEL rule is type: 'script'. For script rules, the condition is the failure predicate — when it evaluates TRUE the validation fails, so invert the test (here amount <= 0 rejects non-positive amounts).

Hook condition

{
  name: 'notify_on_escalation',
  object: 'case',
  events: ['afterUpdate'],
  condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
  body: {
    language: 'js',
    source: "ctx.log.warn('case escalated', { id: ctx.input.id });",
    capabilities: ['log'],
  },
}

The condition is the CEL part — it binds record / previous directly. The JS body is a different surface: it is wrapped as new AsyncFunction('ctx', source), so inside it the record is ctx.input (there is no bare record), and every ctx API it touches must be covered by a declared capability.

Transitions on bulk writes

The condition above is a transition — record.status == 'escalated' alone would be true on every update of an already-escalated case, so "just became" is only expressible by comparing against previous.

Write it once. A predicate (multi: true) write is N record changes, so hooks in both phases — before* and after* alike, and the record-change flow triggers that ride the latter — are evaluated and fired once per matched row, with previous bound to that row's own pre-write state and record holding that row's own state (stored ⊕ payload) rather than the bare payload. The same condition therefore means the same thing whether the write targets one id or matches a thousand rows:

await data.update('case', { status: 'escalated' }, { multi: true, where: { severity: 'high' } });
// → `notify_on_escalation` fires once per case that ACTUALLY transitioned;
//   cases already escalated do not fire.

The matched rows are read once for the whole batch and reused for every per-row evaluation in both phases, so this costs one extra query per write, not one per row. Above 10 000 matched rows a predicate write against an object carrying per-row hooks in either phase is refused rather than fanned out — one ceiling covers before* and after* together, and it is checked before the first dispatch, so nothing is written and no handler runs. Paginate the write. The refusal is loud (ERR_BULK_PER_ROW_HOOK_LIMIT, naming the event, the matched count and the limit); the platform never silently downgrades it to a single hook call.

`before*` hooks dispatch per row too, but what they WRITE is still batch-scoped. A transition *condition* is safe in either phase — `previous` is that row's own pre-image in `beforeUpdate` / `beforeDelete` as much as in `after*`. The payload is the asymmetry: `driver.updateMany` takes one SET clause for N rows, so every per-row `before*` context carries **the** one payload, and a rewrite made on any row's dispatch lands on every matched row. What the contract admits is therefore a **row-invariant-in-effect** rewrite — you may DECIDE per row, but the key set you write must be the same for every row and must be assigned **in place** (`ctx.input.data.customized = true`). The engine enforces it: it records the keys each row's chain assigned and refuses the whole batch before any write when two rows disagree (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, HTTP 400, nothing written). Two shapes slip past that guard and stay out of contract anyway — writing the same key with a per-row **value** (key sets match, so the last dispatch's value silently wins for all rows), and **replacing** `ctx.input.data` instead of mutating it (the recording sees nothing, so the batch is judged by nothing at all). Need a genuinely per-row write? Write those rows by id, or go through `ctx.api` from inside the handler.

Formula patterns

Complex calculations and logic in formula fields.

Conditional Logic

Formula fields are CEL. Use ternary cond ? a : b for conditionals and &&/||/! for boolean logic. Reference fields via record.<field>:

// Tiered pricing
Field.formula({
  label: 'Discount Tier',
  returnType: 'text',
  expression: `
    record.amount > 1000000 ? "Platinum" :
    record.amount > 500000 ? "Gold" :
    record.amount > 100000 ? "Silver" : "Bronze"
  `,
})

// Status indicator
Field.formula({
  label: 'Health Score',
  returnType: 'text',
  expression: `
    record.is_active && record.days_since_contact < 30 ? "Healthy" :
    record.days_since_contact < 90 ? "At Risk" : "Critical"
  `,
})

Date Calculations

// Days until close
Field.formula({
  label: 'Days to Close',
  returnType: 'number',
  expression: 'daysBetween(today(), record.close_date)',
})

// Contract end in 30 days
Field.formula({
  label: 'Expiring Soon',
  returnType: 'boolean',
  expression: 'record.status == "active" && daysBetween(today(), record.end_date) <= 30',
})

// Age in days
Field.formula({
  label: 'Age (Days)',
  returnType: 'number',
  expression: 'daysBetween(record.created_date, today())',
})
**Argument order matters.** `daysBetween(a, b)` is `b − a`, so it counts *forward from `a` to `b`* and goes negative when `b` is earlier. "Days remaining" is `daysBetween(today(), record.due_date)`; "age so far" is `daysBetween(record.created_date, today())`. Swapping the two operands is the easiest way to ship a formula whose sign is inverted on every row. **`dateField == today()` now matches (#3183, #7168).** A `date` field reads back as a `YYYY-MM-DD` string, and CEL treats a string and a timestamp as unequal — so the natural "is it due today" predicate used to silently return `false`. The engine now rewrites temporal `==` / `!=` comparisons (coercing the field operand with `date(...)`), so `record.due_date == today()` matches on the calendar day. The counterpart may be a temporal **call** (`today()`, `now()`, `daysFromNow()`, `daysAgo()`) or a **binding that holds a `Date`** — which covers the mixed-provenance comparison `record.due == previous.due`, where `previous` arrives from the driver as a `Date` while `record` arrives from a JSON payload as a `YYYY-MM-DD` string (#7168). This applies to formulas, defaults, validation rules, and hook/flow conditions. Ordering (`< > <= >=`) and string equality (`record.d == "2026-06-20"`) were always fine. The coercion is deliberately narrow — it requires one side to be a real `Date` and the other an ISO-8601 date/date-time string — so a non-date string compared against a `Date` still answers `false` instead of being coerced into a match. This is the read-side counterpart to the date-arithmetic build error above — equality is rewritten and works; `+`/`-` arithmetic is rejected.

Financial Calculations

// Gross margin (computes a number; a formula's type is always 'formula' —
// declare the value type with returnType, and decimal places with scale)
Field.formula({
  label: 'Gross Margin %',
  returnType: 'number',
  expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0',
  scale: 2,
})

// Weighted pipeline
Field.formula({
  label: 'Weighted Amount',
  returnType: 'number',
  expression: 'record.amount * (record.probability / 100)',
  scale: 2,
})

// Total with tax
Field.formula({
  label: 'Total with Tax',
  returnType: 'number',
  expression: 'record.subtotal * 1.0825', // 8.25% tax
  scale: 2,
})

Text Manipulation

// Uppercase
Field.formula({
  label: 'Code',
  returnType: 'text',
  expression: 'upper(record.sku)',
})

// Has a company email
Field.formula({
  label: 'Internal',
  returnType: 'boolean',
  expression: 'endsWith(lower(record.email), "@example.com")',
})

Lead scoring

// Formula: Lead score
Field.formula({
  label: 'Lead Score',
  returnType: 'number',
  expression: `
    (record.rating == "hot" ? 30 : record.rating == "warm" ? 20 : 10) +
    (record.annual_revenue > 1000000 ? 25 : 0) +
    (record.number_of_employees > 500 ? 20 : 0) +
    (record.industry in ["technology", "finance"] ? 15 : 0)
  `,
})

Best practices

✅ DO:

  • Keep formulas simple and readable
  • Add comments for complex logic
  • Test edge cases (null, zero, empty)
  • Use appropriate data types

❌ DON'T:

  • Create circular references
  • Use formulas for frequently changing data
  • Nest ternaries so deeply the expression stops being readable
  • Ignore null handling

Determinism contract

Two consecutive os build runs with no source changes must produce byte-identical dist/objectstack.json. CEL plus pinned now is what makes this possible — there are zero compile-time-evaluated Date.now() calls in seed metadata: a CEL seed value travels into the artifact as unevaluated source and only resolves at install. This is a design contract held by review, not a gate — no CI job currently diffs two builds — so if you add a new dialect or stdlib helper, ensure it preserves determinism.


Migration from the legacy formula DSL

The previous custom recursive-descent engine (packages/objectql/src/formula.ts, deleted in M9.5) had a Salesforce-style function set. Mechanical translation:

Legacy CEL equivalent
bare_field record.bare_field
= / <> == / !=
AND / OR / NOT && / || / !
"text" 'text' (single quotes preferred)
IF(c, a, b) c ? a : b
ISBLANK(x) isBlank(record.x)
CONCAT(a, b, …) a + b + … (with coalesce(...,''))
TODAY() / NOW() today() / now()
IN (a, b) record.x in [a, b]
ISCHANGED(x) (in update hook) previous.x != record.x
MONTH_DIFF(a, b) not yet — add to stdlib

Salesforce compatibility is not a project goal. Authors targeting Salesforce semantics rewrite their formulas in CEL.


Programmatic API

import { ExpressionEngine } from '@objectstack/formula';

// Compile — parse + type-check, returning the engine-native AST as `value`
const compiled = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x * 2' });

// Evaluate
const result = ExpressionEngine.evaluate(
  { dialect: 'cel', source: 'record.x * 2' },
  { record: { x: 21 } },
);
// => { ok: true, value: 42 }

The low-level engine never throws — evaluate() returns { ok: false, error }. But call sites must not silently swallow that (ADR-0032): os build fails on an invalid expression (with a located, schema-aware message), and at runtime the flow/rule engines throw a loud, attributed error instead of treating a bad expression as false/null. The same validateExpression validator backs os build and metadata registration (and the agent-callable validate_expression tool), so an expression is checked before it ships.


Related