Skip to content

Commit 4840720

Browse files
perf(formula): reuse one CEL environment per entry point instead of building the stdlib on every compile and evaluate (#24629)
Fixes #24536 Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue) Dev session `session_01QNSdgij7mWezAKUbkzSmmR`, dispatched by the `domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`) under claim 6272538729. ## What changes `celEngine.evaluate` and `celEngine.compile` built a new cel-js `Environment` on every call and registered the whole stdlib into it (`buildEnv` → `registerStdLib` + `registerNumericCoercions`). objectql's `applyFormulaPlan` reaches `evaluate` once per row per formula field. Three commits change that: 1. **`79fde52c` — one evaluation environment, with a per-evaluation binding slot.** The stdlib's closures (`now()`, `today()`, `daysFromNow()`, `daysAgo()`, `can`, `hasCapability`) now read the clock, the timezone and the `PermissionBinding` through a reader (`registerBoundStdLib(env, read)`, package-internal in `stdlib.ts`). `cel-engine.ts` builds the shared environment once with a reader pointed at an unexported module binding, `activeBinding`. `evaluate` saves it, sets its own binding and restores the saved one in a `finally`. A nested evaluation and a thrown one therefore both leave the outer binding exactly as it was, and no binding outlives its evaluation. `can` still answers about the acting subject by IDENTITY: the binding is made from the `EvalUser` that `buildScope` mints, as before. 2. **`b5ec0a02` — `compile` reuses `canonicalParseEnv`** (`??=`), the environment `parseCelToAst` already shares. 3. **`8eb3b5a8` — a bounded per-source program cache on the evaluation environment.** Taken because the measurement below shows parsing and checking were most of the remaining cost once the environment was reused. - **Key:** the source `env.parse` is handed, AFTER `rewriteTemporalEquality` (row-dependent), `rewriteNullableTernary` and the overload retry's `rewriteFaultedOperands`. A rewrite decided by the row is part of the key. - **Bound:** 500 entries, oldest out first, the same shape as `temporalRewriteCache`. - **Invalidation:** none is needed. Every entry belongs to the one evaluation environment, whose registrations never change after it is built. A source that does not parse is never stored, so it is reparsed and refused every time. - **One run at a time:** a cached program is marked while it runs, and a nested evaluation of the same source parses a private program (commit `95f434429`). `buildEnv` keeps its signature and builds through the same constructor as the shared environment (`buildEnvReading`). So the stdlib drift pin and the unknown-function oracle still measure the environment that evaluates. **No public surface change.** `index.ts` is untouched. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures, and each still answers from the values its caller pinned: each is now its `bind*` twin with a reader that always returns those values. `celEngine` gains no member. The new names `registerBoundStdLib` and `StdLibBinding` are exported from `stdlib.ts` for `cel-engine.ts` only, and `index.ts` re-exports neither. ## What a cel-js `Environment` holds, and what a cached program holds Read in `@marcbachmann/cel-js` 8.0.0 (`lib/evaluator.js`, `lib/registry.js`, `lib/parser.js`, `lib/type-checker.js`, `lib/operators.js`, `lib/macros.js`). **The environment** carries no per-evaluation state: - each evaluation builds its own `RootContext`, which holds the variable values and the only value-level cache, `#convertCache`; - `Parser.parse` resets its input and its limit counters on every call; - the registry's caches (`Candidates` match and check, type declarations, list, map and optional types, `TypeDeclaration#matchesCache`) are keyed on types; - the registry locks only when it is cloned, and nothing registers on the shared environment after it is built. **A parsed program is not free of evaluation state.** cel-js keeps per-run state on the AST: - a comprehension (`exists`, `all`, `exists_one`, `map`, `filter`) keeps its iteration context on the AST (`args.iterCtx`, `operators.js:667`), and `cel.bind` keeps its binding context there (`bindCtx`, `macros.js:195`); - each run re-parents that overlay to its own scope (`OverlayContext.reuse`, `registry.js:1423`), and every non-iteration variable read inside the step goes through that parent; - `callFn` / `callRecFn` write the arguments' runtime types into `ast.argTypes` on every call (`operators.js:302`, `:319`). In sequential use each of these is overwritten before it is read, so a cached program answers exactly as a fresh parse does. Two runs of ONE AST at once do not: a run re-entered from inside its own comprehension would read the inner run's scope. So a cached program serves one run at a time (`runningPrograms`), and a nested evaluation of the same source parses a private program. Runs are synchronous: this environment registers no async function, and cel-js refuses a Promise-valued scope value (`Unsupported type: Promise`, measured). A cached program also keeps its last evaluation's scope (the `RootContext` its overlays point at, and their `iterValue` / `accuValue`) reachable until the next evaluation of that source, bounded by the cache's 500 entries. The three inputs that did vary per evaluation (now, timezone, binding) are the ones moved into the slot. **Timezone: bound per evaluation, not a cache keyed on it.** With the timezone in the slot, one environment serves every zone, so no per-timezone cache exists to bound. ## Measurement (M) Taken on a composition with formula fields: `ObjectQL` plus `ObjectStackProtocolImplementation` and a stub driver, 25 rows, three formula fields (`joinNonEmpty`, `record.amount / 1000`, `date(record.due) less-than today()`). The probe was a throwaway vitest file, not committed. It counts cel-js `registerFunction('now(): …')` (stdlib builds) and `registerOperator('double + int')` (`buildEnv` builds). The clock is a shared box, so read the ratios, not the absolutes. | reading | `origin/main` c03b17d | after env reuse (`b5ec0a02`) | after program cache (`8eb3b5a8`) | |---|---|---|---| | environment builds per `evaluate` (20,000 evaluations of one source) | 20,000 (1 each) | 0 | 0 | | environment builds per read (25 rows × 3 formulas) | 78 | 0 | 0 | | µs per `celEngine.evaluate`, one source × 20,000 | 141.7 | 9.1 | 3.2 | | µs per `celEngine.compile`, one source × 20,000 | 159.2 | 5.2 | 7.7 (no change on this path, box noise) | | reference: bare cel-js parse+evaluate on a prebuilt env / evaluate of a parsed program | 6.9 / 0.76 | 6.2 / 0.88 | 6.7 / 0.55 | The served body of that read (`protocol.findData`, 3,625 bytes of JSON) is **byte-identical** before and after: compared as strings, `true`. The program cache was taken on this reading. After the environment reuse, about 6 of the remaining 9 µs was parse and check, against under 1 µs of evaluation. ## Pins - `packages/formula/src/cel-env-reuse.test.ts` counts cel-js `Environment` constructions through a `vi.mock` subclass, so every path is seen. - A positive control: `buildEnv` is counted. - Repeated `evaluate` of one source builds 0. - Varying `now`, timezone, subject, permissions and capabilities builds 0. - Repeated `compile` builds 0. - The program cache: the #7168 binding rewrite on alternating rows keeps each row's own answer, and so does the overload retry on alternating rows. Parse, check, runtime and unknown-function faults are reported identically on every evaluation. - `packages/formula/src/cel-eval-binding-isolation.test.ts`: - An evaluation with no subject, after one with a subject, answers `can` and `hasCapability` exactly as a `buildEnv` environment with no binding does. Same `kind`, same message, including after a THROWN evaluation. - An evaluation nested inside another leaves the outer binding as it was. The nesting is a scope getter that runs a whole inner evaluation, and it is tested when the inner one returns, when it throws, and for the outer `now()` and timezone. - `now` and the timezone are each evaluation's own. - `packages/formula/src/cel-program-reentry.test.ts` (fix round 1, `95f434429`): the same source, nested through a scope getter that evaluates it for another subject inside `exists(…)`, `map(…)` and `cel.bind(…)`, answers for the outer subject, equal to a fresh `buildEnv` parse, with the cache warmed first. Red on `1040a3a0c` (3 failed / 1 passed). **Ablations, each red and then restored.** Each used `node scripts/ablation-replace.mjs`: anchor hit ×1 → ×0, blob moved, restore blob == HEAD `5c934212c2f8` and `git diff HEAD` empty. The formula suite imports `./cel-engine` from `src` (no `dist` on the path), so no rebuild leg applies. | ablation | red | |---|---| | A1 — evaluation env reuse removed (`(evaluationEnv ??= buildEnvReading(…))` → `buildEnvReading(…)`) | `repeated evaluations of one source build no new environment`, `what varies per evaluation … builds no environment either`: 2 failed / 17 passed | | A2 — compile env reuse removed (`(canonicalParseEnv ??= buildEnv(…))` → `buildEnv(…)`) | `repeated compiles build no new environment`: 1 failed / 9 passed | | B — the `finally` restore dropped (`activeBinding = outer;` deleted) | all three nested-evaluation pins: 3 failed / 6 passed | | C — the one-run-at-a-time guard dropped (`else if (runningPrograms.has(program))` → `else if (false)`, fix round 1) | the three nested same-source pins: 3 failed / 1 passed | B's first attempt passed an empty `--replacement`, which the tool refused (`the replacement count moved 0 -> 0`) and restored. It was re-run with `--delete`, and that run is the one cited. **Pin sweep.** I grepped every test that names `buildEnv`, `registerStdLib`, `registerPermissionPredicate`, `registerCapabilityPredicate` or `PermissionBinding`, plus the predicates' refusal messages (`ACTING SUBJECT`, `carries no permission data`, `carries no capability list`): - formula: `cel-stdlib-drift`, `unknown-function`, `validate`, `permission-predicate`, `capability-predicate`; - objectql: `engine-formula-default-permission`, `engine-option-permission-predicate`, `validation/rule-validator.option-visibility`, and every `engine-formula-*`. **None moved.** All of them ran green unedited in the full suites below, the stdlib drift pin and the unknown-function oracle included. ## Verification All runs are at HEAD `1040a3a0c` (after merging `origin/main` `8fbbd5082`) unless noted. - `pnpm --filter @objectstack/formula typecheck` (`tsc --noEmit` + `check:test-typecheck`): exit 0, before and after the merge. - `pnpm --filter @objectstack/formula test`: 50 files, 1363 tests passed, exit 0 (post-merge). - Fix round 1, at `95f434429`: `pnpm --filter @objectstack/formula test` 51 files, 1367 passed; formula typecheck and build exit 0; `pnpm --filter @objectstack/core test:repo` 82 passed; the 55 gate commands derived for this round's files all exit 0. The other 11 of the 66 read only the changeset, which this round did not touch. - `pnpm --filter @objectstack/objectql test` (`--project local`): 447 files, 8414 tests passed, exit 0 (at `826ce6ae`, pre-merge; the merge touched neither package). - `pnpm --filter @objectstack/objectql test:repo`: 1 file, 5 tests, exit 0. - `pnpm --filter @objectstack/core test:repo`: 6 files, 82 tests, exit 0. - `pnpm --filter '@objectstack/objectql...' build`: exit 0 (post-merge). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 66 commands. All 66 ran with exit 0. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, no `dist/`); after `pnpm build` it was re-run and exited 0. Reconciliation: `✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3).` ## Acceptance notes - `collectCelRootIdentifiers` (`cel-engine.ts`) still builds a fresh environment per call: `buildEnv(() => new Date(0)).parse(…)`. Its runtime callers include objectql's `rule-validator.ts` and plugin-approvals' `approval-service.ts`. It sits outside this card's file surface (`buildEnv`, `compile`, `evaluate`) and was not measured here. `canonicalParseEnv` is the same reuse shape. Carrier: none. - The per-timezone cache the card offered as an alternative was not built: the timezone is bound per evaluation instead (above). --- _Generated by [Claude Code](https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c7741d commit 4840720

7 files changed

Lines changed: 604 additions & 62 deletions

File tree

‎.changeset/24536-cel-env-reuse.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/formula': patch
3+
---
4+
5+
perf(formula): a CEL evaluation reuses one environment instead of building the stdlib on every call, and compiles reuse the canonical parse environment
6+
7+
Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue)
8+
9+
- **`celEngine.evaluate` reuses one environment.** It used to build a new cel-js `Environment` and register the whole stdlib into it on every call, which objectql's formula fields reach once per row per field. The stdlib now reads `now()`, the timezone and the permission binding of `current_user.can` / `current_user.hasCapability` from a per-evaluation slot. `evaluate` saves the slot, sets it and restores it in a `finally`, so a nested or a thrown evaluation leaves the outer binding as it was. Evaluations also run a bounded per-source cache of parsed programs, keyed on the source after its row-dependent rewrites.
10+
- **`celEngine.compile` reuses the canonical parse environment** that `parseCelToAst` already shares.
11+
- Results, error kinds and error messages are unchanged. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures and their behaviour.
12+
- Measured on one source evaluated 20,000 times: about 142 µs per evaluation before, about 3 µs after. A 25-row read of an object with three formula fields built 78 environments before and builds none after.

‎packages/formula/src/cel-engine.ts‎

Lines changed: 179 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { Environment, EvaluationError, ParseError, serialize } from '@marcbachma
1717
import type { ASTNode } from '@marcbachmann/cel-js';
1818
import type { Expression } from '@objectstack/spec';
1919

20-
import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib';
21-
import type { PermissionBinding } from './stdlib';
20+
import { buildScope, registerBoundStdLib, registerNumericCoercions, registerStdLib } from './stdlib';
21+
import type { PermissionBinding, StdLibBinding } from './stdlib';
2222
import type { DialectEngine, EvalContext, EvalResult } from './types';
2323
import type { CelRootsRefusalCode, ExpressionRefusal } from './expression-refusal';
2424

@@ -58,7 +58,8 @@ export const CEL_ENV_OPTIONS = {
5858
* {@link registerStdLib} and {@link registerNumericCoercions}.
5959
*
6060
* Exported (package-internal; NOT in `index.ts`) so the stdlib drift pin reads
61-
* the authoritative environment through the same constructor the engine uses.
61+
* the authoritative environment through the same constructor the engine uses
62+
* ({@link buildEnvReading}, which also builds the shared evaluation environment).
6263
*
6364
* `permissionBinding` is the acting subject plus its effective object
6465
* permissions and held system capabilities, pinned for this one evaluation
@@ -74,8 +75,125 @@ export function buildEnv(
7475
timezone = 'UTC',
7576
permissionBinding?: PermissionBinding,
7677
): Environment {
78+
const pinned: StdLibBinding = { now, timezone, permissionBinding };
79+
return buildEnvReading(() => pinned);
80+
}
81+
82+
/**
83+
* The one constructor behind {@link buildEnv} and the shared evaluation
84+
* environment: the same options and the same registrations, with the stdlib
85+
* reading its `now`, timezone and permission binding through `read`.
86+
*/
87+
function buildEnvReading(read: () => StdLibBinding): Environment {
7788
const env = new Environment(CEL_ENV_OPTIONS);
78-
return registerNumericCoercions(registerStdLib(env, now, timezone, permissionBinding));
89+
return registerNumericCoercions(registerBoundStdLib(env, read));
90+
}
91+
92+
/**
93+
* What the shared evaluation environment's stdlib answers from outside any
94+
* evaluation: no permission binding, so `can` / `hasCapability` refuse exactly
95+
* as an environment built without one does. No function of that environment
96+
* runs outside `celEngine.evaluate`, which always sets its own binding.
97+
*/
98+
const UNBOUND: StdLibBinding = { now: () => new Date(), timezone: 'UTC', permissionBinding: undefined };
99+
100+
/**
101+
* The binding of the evaluation now running on {@link evaluationEnv}, or
102+
* {@link UNBOUND} when none is.
103+
*
104+
* `celEngine.evaluate` saves it, sets its own and restores the saved one in a
105+
* `finally`, so a nested evaluation and a thrown one both leave the outer
106+
* binding exactly as it was, and no binding outlives its evaluation.
107+
*
108+
* Every evaluation runs to completion inside `evaluate`: cel-js goes
109+
* asynchronous only for a function registered as async, and this environment
110+
* registers none. A Promise-valued scope value is refused (`Unsupported type:
111+
* Promise`, a `runtime` fault), so no continuation ever reads the slot later.
112+
*/
113+
let activeBinding: StdLibBinding = UNBOUND;
114+
115+
/**
116+
* The evaluation environment, built once and reused by every
117+
* `celEngine.evaluate` (#24536). Building the stdlib per evaluation was most
118+
* of an evaluation's cost.
119+
*
120+
* Sharing it is sound because a cel-js `Environment` keeps no per-evaluation
121+
* state: each evaluation gets its own `RootContext`, the parser resets its
122+
* input and limit counters at the start of every parse, and what the registry
123+
* caches (type declarations, overload resolution) is keyed on types, never on
124+
* values. What did vary per evaluation, the stdlib's `now`, timezone and
125+
* permission binding, is read from {@link activeBinding}.
126+
*/
127+
let evaluationEnv: Environment | undefined;
128+
129+
/**
130+
* Programs parsed on {@link evaluationEnv}, keyed on the source `env.parse` was
131+
* handed (#24536).
132+
*
133+
* With the environment built once, parsing and type-checking were most of what
134+
* an evaluation still cost, and one source is evaluated once per row. A cel-js
135+
* program is the parse-once, evaluate-many form of `env.evaluate`.
136+
*
137+
* ⚠️ A program is NOT free of evaluation state. cel-js keeps per-run state on
138+
* the AST itself:
139+
* - a comprehension (`exists`, `all`, `exists_one`, `map`, `filter`) keeps
140+
* its iteration context on the AST (`args.iterCtx`), and `cel.bind` keeps
141+
* its binding context there too (`bindCtx`). Each run re-parents that
142+
* overlay to its own scope (`OverlayContext.reuse`), and every variable the
143+
* step reads goes through that parent;
144+
* - a function call writes its arguments' RUNTIME types into the AST's
145+
* `argTypes` on every call, and then reads them back to pick an overload.
146+
*
147+
* In sequential use each of these is overwritten before it is read, so a
148+
* cached program answers exactly as a fresh parse would. Two runs of ONE AST
149+
* at once do not: a run re-entered from inside its own comprehension (a scope
150+
* getter that evaluates the same source for another subject) would re-parent
151+
* the outer run's overlay, and the outer run would read the inner run's
152+
* `current_user` and `record`. So a program serves one run at a time
153+
* ({@link runningPrograms}). A cached program also keeps its last
154+
* evaluation's scope (the `RootContext` its overlays point at, and their
155+
* `iterValue` / `accuValue`) reachable until the next evaluation of that
156+
* source, bounded by the cache's size.
157+
*
158+
* The key is the source AFTER the rewrites (`rewriteTemporalEquality`,
159+
* `rewriteNullableTernary`, the overload retry's `rewriteFaultedOperands`), so a
160+
* rewrite that depends on the row's values is part of the key, never applied
161+
* from another row. Nothing invalidates an entry: every entry belongs to the
162+
* one environment, whose registrations never change after it is built. Bounded
163+
* like `temporalRewriteCache`, oldest entry out first. A source that does
164+
* not parse is never stored; it is reparsed, and refused, every time.
165+
*/
166+
const evaluationPrograms = new Map<string, ReturnType<Environment['parse']>>();
167+
const EVALUATION_PROGRAM_CACHE_MAX = 500;
168+
169+
/**
170+
* The programs with a run in progress. A run that finds its source's cached
171+
* program here, which is a nested evaluation of the same source, parses a
172+
* private one instead, so no AST is ever run twice at once. Runs are
173+
* synchronous (see {@link activeBinding}), so a run is over when the program
174+
* returns or throws.
175+
*/
176+
const runningPrograms = new Set<ReturnType<Environment['parse']>>();
177+
178+
/** Evaluate `source` on {@link evaluationEnv} through its cached program. */
179+
function runProgram(env: Environment, source: string, scope: Record<string, unknown>): unknown {
180+
let program = evaluationPrograms.get(source);
181+
if (program === undefined) {
182+
program = env.parse(source);
183+
if (evaluationPrograms.size >= EVALUATION_PROGRAM_CACHE_MAX) {
184+
const first = evaluationPrograms.keys().next().value;
185+
if (first !== undefined) evaluationPrograms.delete(first);
186+
}
187+
evaluationPrograms.set(source, program);
188+
} else if (runningPrograms.has(program)) {
189+
return env.parse(source)(scope);
190+
}
191+
runningPrograms.add(program);
192+
try {
193+
return program(scope);
194+
} finally {
195+
runningPrograms.delete(program);
196+
}
79197
}
80198

81199
/**
@@ -391,10 +509,9 @@ export type CelRootIdentifiersResult =
391509
export type CelAstNode = ASTNode;
392510

393511
/**
394-
* The canonical parse env. Identical in configuration to the one
395-
* {@link celEngine.compile} builds per call — same `unlistedVariablesAreDyn`,
396-
* same `enableOptionalTypes`, same {@link DEFAULT_LIMITS}, same stdlib — and
397-
* built once because `parse` neither mutates the environment nor depends on the
512+
* The canonical parse env, shared with {@link celEngine.compile} (#24536) —
513+
* {@link buildEnv}'s `unlistedVariablesAreDyn`, `enableOptionalTypes`,
514+
* {@link DEFAULT_LIMITS} and stdlib — and built once because `parse` neither mutates the environment nor depends on the
398515
* `now()` it was given (the same reasoning `recordScopeEnv` already relies on).
399516
* The parity suite pins the equivalence against a freshly-built env, so this
400517
* memo cannot silently drift away from `compile`.
@@ -1747,9 +1864,9 @@ export const celEngine: DialectEngine = {
17471864

17481865
compile(source: string): EvalResult<unknown> {
17491866
try {
1750-
// We use a wall-clock now() here purely for parse-time stdlib
1751-
// type-checking; the function is never actually called.
1752-
const env = buildEnv(() => new Date(0));
1867+
// The canonical parse env (#24536): parsing and checking neither mutate
1868+
// it nor call the stdlib, so one environment serves every compile.
1869+
const env = (canonicalParseEnv ??= buildEnv(() => new Date(0)));
17531870
// #3306 — accept the null-guard idiom `cond ? <value> : null`, which cel-js's
17541871
// ternary unifier otherwise rejects (`int`/`double`/`string` ≠ `null`). Same
17551872
// rewrite the runtime uses, so build and eval agree on what is valid.
@@ -1799,54 +1916,64 @@ export const celEngine: DialectEngine = {
17991916
try {
18001917
// Scope FIRST: `can` is answered about the acting subject by IDENTITY, and
18011918
// the subject is the canonical `EvalUser` object `buildScope` mints. The
1802-
// environment therefore has to be built from the scope, not beside it —
1919+
// binding therefore has to be made from the scope, not beside it —
18031920
// rebuilding a lookalike user here would give `current_user.can(…)` a
18041921
// receiver that is equal to the bound one and not the same as it.
18051922
const scope = buildScope(ctx);
1806-
const env = buildEnv(now, ctx.timezone ?? 'UTC', {
1807-
subject: scope.current_user,
1808-
permissions: ctx.permissions,
1809-
systemPermissions: ctx.systemPermissions,
1810-
});
1811-
// #3183 — coerce a date-field operand compared with `==`/`!=` against a
1812-
// temporal function (`date(record.d) == today()`), so a `Field.date` string
1813-
// matches the Timestamp instead of silently never equalling it. No-op (and
1814-
// no reserialize) for any source without such a comparison.
1815-
// #7168 — `scope` extends that to a Date-valued BINDING counterpart
1816-
// (`record.due == previous.due`, `previous` hydrated by the driver): the
1817-
// runtime type of a binding is not in the AST, so this arm is decided per
1818-
// row against the values in hand. Non-date operands are untouched.
1819-
// #3306 — then relax the null-guard idiom `cond ? <value> : null` so a
1820-
// nullable numeric/string formula evaluates instead of faulting cel-js's
1821-
// ternary unifier. All rewrites are no-ops for sources that don't need them.
1822-
const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope));
1923+
const env = (evaluationEnv ??= buildEnvReading(() => activeBinding));
1924+
const outer = activeBinding;
1925+
activeBinding = {
1926+
now,
1927+
timezone: ctx.timezone ?? 'UTC',
1928+
permissionBinding: {
1929+
subject: scope.current_user,
1930+
permissions: ctx.permissions,
1931+
systemPermissions: ctx.systemPermissions,
1932+
},
1933+
};
18231934
try {
1824-
const raw = env.evaluate(evalSource, scope);
1825-
return { ok: true, value: coerce(raw) as T };
1826-
} catch (err) {
1827-
// ADR-0032 §1c — string-serialized fields make CEL raise
1828-
// `no such overload`: numeric fields (`rating` → `"5.0"`,
1829-
// `amount` → `"250000.00"`) on `record.rating >= 4` (#1534), and
1830-
// date/datetime fields (`end_date` → `"2026-06-20"`) on
1831-
// `record.end_date <= daysFromNow(60)` (#1530), since cel-js compares the
1832-
// raw string against the `google.protobuf.Timestamp` from `today()` etc.
1833-
// Coerce those operands — and ONLY those — and retry ONCE. #7098: the
1834-
// coercion is per operand POSITION, not scope-wide, so a comparison that
1835-
// already evaluated cleanly is never re-interpreted; the scope itself is
1836-
// never rewritten, so a numeric-looking string RETURNED by the expression
1837-
// keeps its type too. When no operand provably faulted, or the retry still
1838-
// cannot type-check, the original loud error is reported.
1839-
if (!isNumericOverloadError(err)) throw err;
1840-
const coercedSource = rewriteFaultedOperands(evalSource, scope);
1841-
if (coercedSource === null) throw err;
1935+
// #3183 — coerce a date-field operand compared with `==`/`!=` against a
1936+
// temporal function (`date(record.d) == today()`), so a `Field.date` string
1937+
// matches the Timestamp instead of silently never equalling it. No-op (and
1938+
// no reserialize) for any source without such a comparison.
1939+
// #7168 — `scope` extends that to a Date-valued BINDING counterpart
1940+
// (`record.due == previous.due`, `previous` hydrated by the driver): the
1941+
// runtime type of a binding is not in the AST, so this arm is decided per
1942+
// row against the values in hand. Non-date operands are untouched.
1943+
// #3306 — then relax the null-guard idiom `cond ? <value> : null` so a
1944+
// nullable numeric/string formula evaluates instead of faulting cel-js's
1945+
// ternary unifier. All rewrites are no-ops for sources that don't need them.
1946+
const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope));
18421947
try {
1843-
const raw = env.evaluate(coercedSource, scope);
1948+
const raw = runProgram(env, evalSource, scope);
18441949
return { ok: true, value: coerce(raw) as T };
1845-
} catch {
1846-
// Hydration did not resolve it — surface the original fault, not the
1847-
// retry's, so the message reflects what the author actually wrote.
1848-
throw err;
1950+
} catch (err) {
1951+
// ADR-0032 §1c — string-serialized fields make CEL raise
1952+
// `no such overload`: numeric fields (`rating` → `"5.0"`,
1953+
// `amount` → `"250000.00"`) on `record.rating >= 4` (#1534), and
1954+
// date/datetime fields (`end_date` → `"2026-06-20"`) on
1955+
// `record.end_date <= daysFromNow(60)` (#1530), since cel-js compares the
1956+
// raw string against the `google.protobuf.Timestamp` from `today()` etc.
1957+
// Coerce those operands — and ONLY those — and retry ONCE. #7098: the
1958+
// coercion is per operand POSITION, not scope-wide, so a comparison that
1959+
// already evaluated cleanly is never re-interpreted; the scope itself is
1960+
// never rewritten, so a numeric-looking string RETURNED by the expression
1961+
// keeps its type too. When no operand provably faulted, or the retry still
1962+
// cannot type-check, the original loud error is reported.
1963+
if (!isNumericOverloadError(err)) throw err;
1964+
const coercedSource = rewriteFaultedOperands(evalSource, scope);
1965+
if (coercedSource === null) throw err;
1966+
try {
1967+
const raw = runProgram(env, coercedSource, scope);
1968+
return { ok: true, value: coerce(raw) as T };
1969+
} catch {
1970+
// Hydration did not resolve it — surface the original fault, not the
1971+
// retry's, so the message reflects what the author actually wrote.
1972+
throw err;
1973+
}
18491974
}
1975+
} finally {
1976+
activeBinding = outer;
18501977
}
18511978
} catch (err) {
18521979
return classifyError(err);

0 commit comments

Comments
 (0)