From 79fde52cacd3fec247e1e059f33d2c74ffabe907 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:10:32 +0000 Subject: [PATCH 1/5] perf(formula): reuse one CEL evaluation environment, binding now/timezone/permissions per evaluation celEngine.evaluate built a fresh Environment and registered the whole stdlib on every call. It now reuses one, and the stdlib reads now(), the timezone and the permission binding from a per-evaluation slot that evaluate saves, sets and restores in a finally. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude --- packages/formula/src/cel-engine.ts | 143 ++++++++++++------ packages/formula/src/cel-env-reuse.test.ts | 78 ++++++++++ .../src/cel-eval-binding-isolation.test.ts | 140 +++++++++++++++++ packages/formula/src/stdlib.ts | 77 ++++++++-- 4 files changed, 384 insertions(+), 54 deletions(-) create mode 100644 packages/formula/src/cel-env-reuse.test.ts create mode 100644 packages/formula/src/cel-eval-binding-isolation.test.ts diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 5845a5aac34..3fe17dd3eea 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -17,8 +17,8 @@ import { Environment, EvaluationError, ParseError, serialize } from '@marcbachma import type { ASTNode } from '@marcbachmann/cel-js'; import type { Expression } from '@objectstack/spec'; -import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib'; -import type { PermissionBinding } from './stdlib'; +import { buildScope, registerBoundStdLib, registerNumericCoercions, registerStdLib } from './stdlib'; +import type { PermissionBinding, StdLibBinding } from './stdlib'; import type { DialectEngine, EvalContext, EvalResult } from './types'; import type { CelRootsRefusalCode, ExpressionRefusal } from './expression-refusal'; @@ -58,7 +58,8 @@ export const CEL_ENV_OPTIONS = { * {@link registerStdLib} and {@link registerNumericCoercions}. * * Exported (package-internal; NOT in `index.ts`) so the stdlib drift pin reads - * the authoritative environment through the same constructor the engine uses. + * the authoritative environment through the same constructor the engine uses + * ({@link buildEnvReading}, which also builds the shared evaluation environment). * * `permissionBinding` is the acting subject plus its effective object * permissions and held system capabilities, pinned for this one evaluation @@ -74,10 +75,52 @@ export function buildEnv( timezone = 'UTC', permissionBinding?: PermissionBinding, ): Environment { + const pinned: StdLibBinding = { now, timezone, permissionBinding }; + return buildEnvReading(() => pinned); +} + +/** + * The one constructor behind {@link buildEnv} and the shared evaluation + * environment: the same options and the same registrations, with the stdlib + * reading its `now`, timezone and permission binding through `read`. + */ +function buildEnvReading(read: () => StdLibBinding): Environment { const env = new Environment(CEL_ENV_OPTIONS); - return registerNumericCoercions(registerStdLib(env, now, timezone, permissionBinding)); + return registerNumericCoercions(registerBoundStdLib(env, read)); } +/** + * What the shared evaluation environment's stdlib answers from outside any + * evaluation: no permission binding, so `can` / `hasCapability` refuse exactly + * as an environment built without one does. No function of that environment + * runs outside `celEngine.evaluate`, which always sets its own binding. + */ +const UNBOUND: StdLibBinding = { now: () => new Date(), timezone: 'UTC', permissionBinding: undefined }; + +/** + * The binding of the evaluation now running on {@link evaluationEnv}, or + * {@link UNBOUND} when none is. + * + * `celEngine.evaluate` saves it, sets its own and restores the saved one in a + * `finally`, so a nested evaluation and a thrown one both leave the outer + * binding exactly as it was, and no binding outlives its evaluation. + */ +let activeBinding: StdLibBinding = UNBOUND; + +/** + * The evaluation environment, built once and reused by every + * `celEngine.evaluate` (#24536). Building the stdlib per evaluation was most + * of an evaluation's cost. + * + * Sharing it is sound because a cel-js `Environment` keeps no per-evaluation + * state: each evaluation gets its own `RootContext`, the parser resets its + * input and limit counters at the start of every parse, and what the registry + * caches (type declarations, overload resolution) is keyed on types, never on + * values. What did vary per evaluation, the stdlib's `now`, timezone and + * permission binding, is read from {@link activeBinding}. + */ +let evaluationEnv: Environment | undefined; + /** * The "never faults" BASELINE: the namespace roots the strict check env * declares, so that a `record`-scoped CEL site is never faulted for merely @@ -1799,54 +1842,64 @@ export const celEngine: DialectEngine = { try { // Scope FIRST: `can` is answered about the acting subject by IDENTITY, and // the subject is the canonical `EvalUser` object `buildScope` mints. The - // environment therefore has to be built from the scope, not beside it — + // binding therefore has to be made from the scope, not beside it — // rebuilding a lookalike user here would give `current_user.can(…)` a // receiver that is equal to the bound one and not the same as it. const scope = buildScope(ctx); - const env = buildEnv(now, ctx.timezone ?? 'UTC', { - subject: scope.current_user, - permissions: ctx.permissions, - systemPermissions: ctx.systemPermissions, - }); - // #3183 — coerce a date-field operand compared with `==`/`!=` against a - // temporal function (`date(record.d) == today()`), so a `Field.date` string - // matches the Timestamp instead of silently never equalling it. No-op (and - // no reserialize) for any source without such a comparison. - // #7168 — `scope` extends that to a Date-valued BINDING counterpart - // (`record.due == previous.due`, `previous` hydrated by the driver): the - // runtime type of a binding is not in the AST, so this arm is decided per - // row against the values in hand. Non-date operands are untouched. - // #3306 — then relax the null-guard idiom `cond ? : null` so a - // nullable numeric/string formula evaluates instead of faulting cel-js's - // ternary unifier. All rewrites are no-ops for sources that don't need them. - const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope)); + const env = (evaluationEnv ??= buildEnvReading(() => activeBinding)); + const outer = activeBinding; + activeBinding = { + now, + timezone: ctx.timezone ?? 'UTC', + permissionBinding: { + subject: scope.current_user, + permissions: ctx.permissions, + systemPermissions: ctx.systemPermissions, + }, + }; try { - const raw = env.evaluate(evalSource, scope); - return { ok: true, value: coerce(raw) as T }; - } catch (err) { - // 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. - // Coerce those operands — and ONLY those — and retry ONCE. #7098: the - // coercion is per operand POSITION, not scope-wide, so a comparison that - // already evaluated cleanly is never re-interpreted; the scope itself is - // never rewritten, so a numeric-looking string RETURNED by the expression - // keeps its type too. When no operand provably faulted, or the retry still - // cannot type-check, the original loud error is reported. - if (!isNumericOverloadError(err)) throw err; - const coercedSource = rewriteFaultedOperands(evalSource, scope); - if (coercedSource === null) throw err; + // #3183 — coerce a date-field operand compared with `==`/`!=` against a + // temporal function (`date(record.d) == today()`), so a `Field.date` string + // matches the Timestamp instead of silently never equalling it. No-op (and + // no reserialize) for any source without such a comparison. + // #7168 — `scope` extends that to a Date-valued BINDING counterpart + // (`record.due == previous.due`, `previous` hydrated by the driver): the + // runtime type of a binding is not in the AST, so this arm is decided per + // row against the values in hand. Non-date operands are untouched. + // #3306 — then relax the null-guard idiom `cond ? : null` so a + // nullable numeric/string formula evaluates instead of faulting cel-js's + // ternary unifier. All rewrites are no-ops for sources that don't need them. + const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope)); try { - const raw = env.evaluate(coercedSource, scope); + const raw = env.evaluate(evalSource, scope); return { ok: true, value: coerce(raw) as T }; - } catch { - // Hydration did not resolve it — surface the original fault, not the - // retry's, so the message reflects what the author actually wrote. - throw err; + } catch (err) { + // 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. + // Coerce those operands — and ONLY those — and retry ONCE. #7098: the + // coercion is per operand POSITION, not scope-wide, so a comparison that + // already evaluated cleanly is never re-interpreted; the scope itself is + // never rewritten, so a numeric-looking string RETURNED by the expression + // keeps its type too. When no operand provably faulted, or the retry still + // cannot type-check, the original loud error is reported. + if (!isNumericOverloadError(err)) throw err; + const coercedSource = rewriteFaultedOperands(evalSource, scope); + if (coercedSource === null) throw err; + try { + const raw = env.evaluate(coercedSource, scope); + return { ok: true, value: coerce(raw) as T }; + } catch { + // Hydration did not resolve it — surface the original fault, not the + // retry's, so the message reflects what the author actually wrote. + throw err; + } } + } finally { + activeBinding = outer; } } catch (err) { return classifyError(err); diff --git a/packages/formula/src/cel-env-reuse.test.ts b/packages/formula/src/cel-env-reuse.test.ts new file mode 100644 index 00000000000..6a2aa60c942 --- /dev/null +++ b/packages/formula/src/cel-env-reuse.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; + +/** + * #24536 — `celEngine.compile` and `celEngine.evaluate` reuse ONE environment + * each instead of building the CEL stdlib on every call. + * + * Measured on `origin/main` c03b17d784 before the change: every `evaluate` + * built a fresh `Environment` and registered the whole stdlib into it, about + * 140 µs of an evaluation that costs about 7 µs on a built environment, and + * objectql's `applyFormulaPlan` reaches `evaluate` once per row per formula + * field. `compile` did the same. + * + * The count is taken on cel-js's own constructor, so it sees every + * environment this package builds, through any path. + */ + +let environmentsBuilt = 0; + +vi.mock('@marcbachmann/cel-js', async (importOriginal) => { + const actual = await importOriginal(); + class CountingEnvironment extends actual.Environment { + constructor(...args: ConstructorParameters) { + super(...args); + environmentsBuilt += 1; + } + } + return { ...actual, Environment: CountingEnvironment }; +}); + +const { celEngine } = await import('./cel-engine'); +const { toEvalPermissions } = await import('./eval-permissions'); + +/** Environments built while `run` executes. */ +function built(run: () => void): number { + const before = environmentsBuilt; + run(); + return environmentsBuilt - before; +} + +const SOURCE = "joinNonEmpty([record.name, coalesce(record.industry, '')], ' - ')"; + +describe('#24536 — one environment per engine entry point, not one per call', () => { + it('the counter sees an environment being built (positive control)', async () => { + const { buildEnv } = await import('./cel-engine'); + expect(built(() => buildEnv(() => new Date(0)))).toBe(1); + }); + + it('repeated evaluations of one source build no new environment', () => { + const expr = { dialect: 'cel' as const, source: SOURCE }; + // The first evaluation may build the shared environment; none after it does. + celEngine.evaluate(expr, { record: { name: 'Acme', industry: 'Tech' } }); + const n = built(() => { + for (let i = 0; i < 50; i++) { + const r = celEngine.evaluate(expr, { record: { name: `Acme ${i}`, industry: i % 2 ? 'Tech' : null } }); + expect(r).toEqual({ ok: true, value: i % 2 ? `Acme ${i} - Tech` : `Acme ${i}` }); + } + }); + expect(n).toBe(0); + }); + + it('what varies per evaluation (now, timezone, subject, permissions) builds no environment either', () => { + const user = { id: 'usr_1', positions: [] }; + const permissions = toEvalPermissions({ crm_lead: { allowRead: true } }); + const run = (i: number) => { + celEngine.evaluate({ dialect: 'cel', source: 'today()' }, { + now: new Date(Date.UTC(2026, 0, 1 + i, 23)), timezone: i % 2 ? 'Asia/Shanghai' : 'UTC', + }); + celEngine.evaluate({ dialect: 'cel', source: "current_user.can('crm_lead', 'read')" }, { user, permissions }); + celEngine.evaluate({ dialect: 'cel', source: "current_user.hasCapability('x')" }, { user, systemPermissions: ['x'] }); + celEngine.evaluate({ dialect: 'cel', source: 'record.amount / 100' }, { record: { amount: 250 } }); + }; + run(0); + expect(built(() => { for (let i = 1; i < 20; i++) run(i); })).toBe(0); + }); + +}); diff --git a/packages/formula/src/cel-eval-binding-isolation.test.ts b/packages/formula/src/cel-eval-binding-isolation.test.ts new file mode 100644 index 00000000000..103af26befd --- /dev/null +++ b/packages/formula/src/cel-eval-binding-isolation.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { buildEnv, celEngine } from './cel-engine'; +import { toEvalPermissions } from './eval-permissions'; +import { buildScope } from './stdlib'; +import type { EvalContext, EvalResult } from './types'; + +/** + * #24536 — `celEngine.evaluate` reuses one environment, and what used to be + * fixed when that environment was built (`now`, the timezone and the + * permission binding) is now bound per evaluation through a slot the stdlib + * reads. These pins hold the slot to the rule that replaces "a fresh + * environment per evaluation": a binding never outlives its evaluation. + * + * - an evaluation with no subject, after one with a subject, answers + * `can` / `hasCapability` exactly as an environment built with no binding + * does — after a normal evaluation and after a thrown one; + * - an evaluation that runs INSIDE another (a scope value whose getter + * evaluates) leaves the outer binding exactly as it was, whether the inner + * one returns or throws. + */ + +const PERMISSIONS = toEvalPermissions({ crm_lead: { allowRead: true } }); +const ALICE = { id: 'usr_alice', positions: [] }; +const BOB = { id: 'usr_bob', positions: [] }; + +function evaluate(source: string, ctx: EvalContext): EvalResult { + return celEngine.evaluate({ dialect: 'cel', source }, ctx); +} + +/** + * A receiver for an evaluation that binds no subject. With no `user` on the + * context there is no `current_user` to call the predicates on, so the + * question an unbound evaluation answers is asked on this one. + */ +const SOMEONE = { id: 'usr_alice', positions: [] }; + +/** + * What an environment built with NO binding answers for `source` — the + * stdlib's own throw, which `evaluate` reports as a `runtime` fault. + */ +function unboundAnswer(source: string): EvalResult { + try { + return { ok: true, value: buildEnv(() => new Date(0)).evaluate(source, buildScope({ extra: { someone: SOMEONE } })) }; + } catch (err) { + return { ok: false, error: { kind: 'runtime', message: (err as Error).message } }; + } +} + +const CAN = "current_user.can('crm_lead', 'read')"; +const HAS = "current_user.hasCapability('manage_users')"; +const UNBOUND_CAN = "someone.can('crm_lead', 'read')"; +const UNBOUND_HAS = "someone.hasCapability('manage_users')"; + +describe('#24536 — an evaluation with no subject after one with a subject', () => { + it('the subject-bound evaluations answer (positive control)', () => { + expect(evaluate(CAN, { user: ALICE, permissions: PERMISSIONS })).toEqual({ ok: true, value: true }); + expect(evaluate(HAS, { user: ALICE, systemPermissions: ['manage_users'] })).toEqual({ ok: true, value: true }); + }); + + const UNBOUND_CTX: EvalContext = { permissions: PERMISSIONS, systemPermissions: ['manage_users'], extra: { someone: SOMEONE } }; + + it.each([UNBOUND_CAN, UNBOUND_HAS])('answers %s as an unbound environment does', (source) => { + const unbound = unboundAnswer(source); + expect(unbound.ok).toBe(false); + expect(unbound.ok === false && unbound.error.message).toContain('ACTING SUBJECT'); + expect(evaluate(CAN, { user: ALICE, permissions: PERMISSIONS })).toEqual({ ok: true, value: true }); + expect(evaluate(HAS, { user: ALICE, systemPermissions: ['manage_users'] })).toEqual({ ok: true, value: true }); + expect(evaluate(source, UNBOUND_CTX)).toEqual(unbound); + }); + + it.each([UNBOUND_CAN, UNBOUND_HAS])('answers %s as an unbound environment does after a THROWN evaluation', (source) => { + const unbound = unboundAnswer(source); + const thrown = evaluate("current_user.can('crm_lead', 'approve')", { + user: ALICE, permissions: PERMISSIONS, systemPermissions: ['manage_users'], + }); + expect(thrown.ok).toBe(false); + expect(evaluate(source, UNBOUND_CTX)).toEqual(unbound); + }); +}); + +describe('#24536 — an evaluation nested inside another leaves the outer binding as it was', () => { + /** + * A record whose `probe` getter runs a whole evaluation for BOB (or one with + * no subject at all) while ALICE's evaluation is reading it. `probe` is read + * first, so every `can` / `hasCapability` / `now()` the outer source calls + * runs after the inner evaluation has finished. + */ + function probing(inner: () => void): Record { + return Object.defineProperty({}, 'probe', { enumerable: true, get: () => { inner(); return true; } }); + } + + const OUTER = `record.probe && ${CAN} && ${HAS}`; + const aliceCtx = (record: Record): EvalContext => ({ + record, user: ALICE, permissions: PERMISSIONS, systemPermissions: ['manage_users'], + }); + + it('after the inner evaluation returns', () => { + let inner: EvalResult | undefined; + const record = probing(() => { + inner = evaluate(CAN, { user: BOB, permissions: toEvalPermissions({}) }); + }); + expect(evaluate(OUTER, aliceCtx(record))).toEqual({ ok: true, value: true }); + expect(inner).toEqual({ ok: true, value: false }); + }); + + it('after the inner evaluation throws', () => { + let inner: EvalResult | undefined; + const record = probing(() => { + inner = evaluate("current_user.can('crm_lead', 'approve')", { user: BOB, permissions: PERMISSIONS }); + }); + expect(evaluate(OUTER, aliceCtx(record))).toEqual({ ok: true, value: true }); + expect(inner?.ok).toBe(false); + }); + + it("after an inner evaluation with no subject, and the outer's now() and timezone too", () => { + const outerNow = new Date('2026-03-01T20:00:00Z'); + const record = probing(() => { + evaluate('today()', { now: new Date('1999-01-01T00:00:00Z') }); + }); + const r = evaluate(`record.probe && ${CAN} ? [now(), today()] : []`, { + ...aliceCtx(record), now: outerNow, timezone: 'Asia/Shanghai', + }); + // 20:00Z on 1 March is 2 March in Shanghai (UTC+8). + expect(r).toEqual({ ok: true, value: [outerNow, new Date('2026-03-02T00:00:00Z')] }); + }); +}); + +describe('#24536 — now and timezone are each evaluation\'s own', () => { + it('alternating clocks and zones answer per evaluation', () => { + const at = new Date('2026-03-01T20:00:00Z'); + for (let i = 0; i < 3; i++) { + expect(evaluate('today()', { now: at, timezone: 'UTC' })).toEqual({ ok: true, value: new Date('2026-03-01T00:00:00Z') }); + expect(evaluate('today()', { now: at, timezone: 'Asia/Shanghai' })).toEqual({ ok: true, value: new Date('2026-03-02T00:00:00Z') }); + expect(evaluate('daysFromNow(1)', { now: new Date('2030-12-31T12:00:00Z') })).toEqual({ ok: true, value: new Date('2031-01-01T00:00:00Z') }); + } + }); +}); diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index 83bc57b0907..ca48b5a1793 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -1,9 +1,8 @@ /** * ObjectStack standard CEL function library. * - * Registered into the per-evaluation `Environment` by the CEL engine. All - * functions are pure given a pinned `now` and the pinned data this module is - * handed — that determinism is what makes `objectstack build` artifacts + * Registered into an `Environment` by the CEL engine. All functions are pure + * given a pinned `now` and the pinned data this module is handed — that determinism is what makes `objectstack build` artifacts * byte-stable across runs. ⛔ Nothing registered here may be handed a resolver, * a lazy getter or any other callback into the host: a function that reaches * outside its arguments and the data pinned at registration makes the same @@ -137,7 +136,7 @@ function addMonthsUtc(d: Date, n: number): Date { * What `current_user.can(object, verb)` is answered from: the acting subject * this evaluation bound, and that subject's effective object permissions. * - * Both halves are DATA, pinned when the environment is built. `subject` is the + * Both halves are DATA, pinned for the one evaluation they answer. `subject` is the * very object {@link buildScope} mounted under `current_user` (and its `user` / * `ctx.user` / `os.user` aliases), carried here so the binding can tell a call * ON the acting subject from a call on something else that happens to sit to @@ -199,10 +198,22 @@ export interface PermissionBinding { export function registerPermissionPredicate( env: Environment, binding: PermissionBinding | undefined, +): Environment { + return bindPermissionPredicate(env, () => binding); +} + +/** + * {@link registerPermissionPredicate}, answered from whatever binding `read` + * returns at the moment of the call rather than from one fixed at registration. + */ +function bindPermissionPredicate( + env: Environment, + read: () => PermissionBinding | undefined, ): Environment { return env.registerFunction( 'dyn.can(dyn, dyn): bool', (receiver: unknown, object: unknown, verb: unknown): boolean => { + const binding = read(); if (typeof object !== 'string' || object.length === 0) { throw new Error( 'can(object, verb): `object` must be an object NAME (a non-empty string), e.g. ' + @@ -284,10 +295,22 @@ export function registerPermissionPredicate( export function registerCapabilityPredicate( env: Environment, binding: PermissionBinding | undefined, +): Environment { + return bindCapabilityPredicate(env, () => binding); +} + +/** + * {@link registerCapabilityPredicate}, answered from whatever binding `read` + * returns at the moment of the call rather than from one fixed at registration. + */ +function bindCapabilityPredicate( + env: Environment, + read: () => PermissionBinding | undefined, ): Environment { return env.registerFunction( 'dyn.hasCapability(dyn): bool', (receiver: unknown, name: unknown): boolean => { + const binding = read(); if (typeof name !== 'string' || name.length === 0) { throw new Error( 'hasCapability(name): `name` must be a system capability NAME (a non-empty string), ' + @@ -346,8 +369,44 @@ export function registerStdLib( timezone = 'UTC', permissionBinding?: PermissionBinding, ): Environment { - registerPermissionPredicate(env, permissionBinding); - registerCapabilityPredicate(env, permissionBinding); + const pinned: StdLibBinding = { now, timezone, permissionBinding }; + return registerBoundStdLib(env, () => pinned); +} + +/** + * Everything a stdlib function reads that is not one of its arguments: the + * clock, the reference timezone and the permission binding. + * + * Package-internal (NOT re-exported from `index.ts`). + */ +export interface StdLibBinding { + readonly now: () => Date; + readonly timezone: string; + readonly permissionBinding: PermissionBinding | undefined; +} + +/** + * {@link registerStdLib}, with every function reading its {@link StdLibBinding} + * through `read` at the moment it is CALLED rather than from values fixed when + * it was registered. + * + * This is what lets the engine build the stdlib once and reuse that + * environment for every evaluation: it points `read` at a per-evaluation slot + * and sets the slot around each `evaluate` (see `celEngine.evaluate`). + * {@link registerStdLib} is this function with a `read` that always answers + * the same pinned values, so both register the same names with the same + * implementations. + * + * Package-internal (NOT re-exported from `index.ts`). + */ +export function registerBoundStdLib(env: Environment, read: () => StdLibBinding): Environment { + bindPermissionPredicate(env, () => read().permissionBinding); + bindCapabilityPredicate(env, () => read().permissionBinding); + const now = (): Date => read().now(); + const today = (): Date => { + const binding = read(); + return calendarDayUtc(binding.now(), binding.timezone); + }; // `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they // resolve to the reference-tz calendar day expressed as a UTC-midnight Date // (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a @@ -356,15 +415,15 @@ export function registerStdLib( .registerFunction('now(): google.protobuf.Timestamp', () => now()) .registerFunction( 'today(): google.protobuf.Timestamp', - () => calendarDayUtc(now(), timezone), + () => today(), ) .registerFunction( 'daysFromNow(int): google.protobuf.Timestamp', - (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)), + (n: bigint | number) => addDaysUtc(today(), Number(n)), ) .registerFunction( 'daysAgo(int): google.protobuf.Timestamp', - (n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)), + (n: bigint | number) => addDaysUtc(today(), -Number(n)), ) // Returns true when `value` is null, undefined, empty string, or empty list. // Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic. From b5ec0a028086914095b93a7fc214800c34a16b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:10:32 +0000 Subject: [PATCH 2/5] perf(formula): compile reuses the canonical parse environment Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude --- packages/formula/src/cel-engine.ts | 13 ++++++------- packages/formula/src/cel-env-reuse.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 3fe17dd3eea..3ec2c3f16fa 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -434,10 +434,9 @@ export type CelRootIdentifiersResult = export type CelAstNode = ASTNode; /** - * The canonical parse env. Identical in configuration to the one - * {@link celEngine.compile} builds per call — same `unlistedVariablesAreDyn`, - * same `enableOptionalTypes`, same {@link DEFAULT_LIMITS}, same stdlib — and - * built once because `parse` neither mutates the environment nor depends on the + * The canonical parse env, shared with {@link celEngine.compile} (#24536) — + * {@link buildEnv}'s `unlistedVariablesAreDyn`, `enableOptionalTypes`, + * {@link DEFAULT_LIMITS} and stdlib — and built once because `parse` neither mutates the environment nor depends on the * `now()` it was given (the same reasoning `recordScopeEnv` already relies on). * The parity suite pins the equivalence against a freshly-built env, so this * memo cannot silently drift away from `compile`. @@ -1790,9 +1789,9 @@ export const celEngine: DialectEngine = { compile(source: string): EvalResult { try { - // We use a wall-clock now() here purely for parse-time stdlib - // type-checking; the function is never actually called. - const env = buildEnv(() => new Date(0)); + // The canonical parse env (#24536): parsing and checking neither mutate + // it nor call the stdlib, so one environment serves every compile. + const env = (canonicalParseEnv ??= buildEnv(() => new Date(0))); // #3306 — accept the null-guard idiom `cond ? : null`, which cel-js's // ternary unifier otherwise rejects (`int`/`double`/`string` ≠ `null`). Same // rewrite the runtime uses, so build and eval agree on what is valid. diff --git a/packages/formula/src/cel-env-reuse.test.ts b/packages/formula/src/cel-env-reuse.test.ts index 6a2aa60c942..15a10d7d609 100644 --- a/packages/formula/src/cel-env-reuse.test.ts +++ b/packages/formula/src/cel-env-reuse.test.ts @@ -75,4 +75,12 @@ describe('#24536 — one environment per engine entry point, not one per call', expect(built(() => { for (let i = 1; i < 20; i++) run(i); })).toBe(0); }); + it('repeated compiles build no new environment', () => { + celEngine.compile(SOURCE); + const n = built(() => { + for (let i = 0; i < 50; i++) expect(celEngine.compile(SOURCE).ok).toBe(true); + expect(celEngine.compile('nope(').ok).toBe(false); + }); + expect(n).toBe(0); + }); }); From 8eb3b5a8eb2d08dd5809f78c6cecd45781418c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:13:47 +0000 Subject: [PATCH 3/5] perf(formula): cache evaluation programs per rewritten source With the environment reused, parsing and checking were most of what an evaluation still cost. evaluate now runs a bounded per-source cache of cel-js programs on the shared evaluation environment, keyed on the source after the row-dependent rewrites. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude --- packages/formula/src/cel-engine.ts | 39 ++++++++++++++++++++-- packages/formula/src/cel-env-reuse.test.ts | 37 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 3ec2c3f16fa..5c934212c2f 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -121,6 +121,41 @@ let activeBinding: StdLibBinding = UNBOUND; */ let evaluationEnv: Environment | undefined; +/** + * Programs parsed on {@link evaluationEnv}, keyed on the source `env.parse` was + * handed (#24536). + * + * With the environment built once, parsing and type-checking were most of what + * an evaluation still cost, and one source is evaluated once per row. A cel-js + * program is the parse-once, evaluate-many form of `env.evaluate`: it holds the + * AST and the types its check recorded, never a value, so it answers every + * scope exactly as a fresh parse would. + * + * The key is the source AFTER the rewrites (`rewriteTemporalEquality`, + * `rewriteNullableTernary`, the overload retry's `rewriteFaultedOperands`), so a + * rewrite that depends on the row's values is part of the key, never applied + * from another row. Nothing invalidates an entry: every entry belongs to the + * one environment, whose registrations never change after it is built. Bounded + * like `temporalRewriteCache`, oldest entry out first. A source that does + * not parse is never stored; it is reparsed, and refused, every time. + */ +const evaluationPrograms = new Map>(); +const EVALUATION_PROGRAM_CACHE_MAX = 500; + +/** Evaluate `source` on {@link evaluationEnv} through its cached program. */ +function runProgram(env: Environment, source: string, scope: Record): unknown { + let program = evaluationPrograms.get(source); + if (program === undefined) { + program = env.parse(source); + if (evaluationPrograms.size >= EVALUATION_PROGRAM_CACHE_MAX) { + const first = evaluationPrograms.keys().next().value; + if (first !== undefined) evaluationPrograms.delete(first); + } + evaluationPrograms.set(source, program); + } + return program(scope); +} + /** * The "never faults" BASELINE: the namespace roots the strict check env * declares, so that a `record`-scoped CEL site is never faulted for merely @@ -1870,7 +1905,7 @@ export const celEngine: DialectEngine = { // ternary unifier. All rewrites are no-ops for sources that don't need them. const evalSource = rewriteNullableTernary(rewriteTemporalEquality(source, scope)); try { - const raw = env.evaluate(evalSource, scope); + const raw = runProgram(env, evalSource, scope); return { ok: true, value: coerce(raw) as T }; } catch (err) { // ADR-0032 §1c — string-serialized fields make CEL raise @@ -1889,7 +1924,7 @@ export const celEngine: DialectEngine = { const coercedSource = rewriteFaultedOperands(evalSource, scope); if (coercedSource === null) throw err; try { - const raw = env.evaluate(coercedSource, scope); + const raw = runProgram(env, coercedSource, scope); return { ok: true, value: coerce(raw) as T }; } catch { // Hydration did not resolve it — surface the original fault, not the diff --git a/packages/formula/src/cel-env-reuse.test.ts b/packages/formula/src/cel-env-reuse.test.ts index 15a10d7d609..34978bb9997 100644 --- a/packages/formula/src/cel-env-reuse.test.ts +++ b/packages/formula/src/cel-env-reuse.test.ts @@ -84,3 +84,40 @@ describe('#24536 — one environment per engine entry point, not one per call', expect(n).toBe(0); }); }); + +describe('#24536 — the per-source program cache answers every row as a fresh parse would', () => { + it('a rewrite decided by the ROW is part of the key: alternating rows keep their own answers', () => { + // #7168: `record.due` is wrapped in `date(…)` only on a row whose + // `previous.due` is a Date, so the two rows run two different programs. + const expr = { dialect: 'cel' as const, source: 'record.due == previous.due' }; + const dateRow = { record: { due: '2026-06-20' }, previous: { due: new Date('2026-06-20T00:00:00Z') } }; + const textRow = { record: { due: '2026-06-20' }, previous: { due: '2026-06-20' } }; + const textOther = { record: { due: '2026-06-20' }, previous: { due: '2026-06-21' } }; + for (let i = 0; i < 3; i++) { + expect(celEngine.evaluate(expr, dateRow)).toEqual({ ok: true, value: true }); + expect(celEngine.evaluate(expr, textRow)).toEqual({ ok: true, value: true }); + expect(celEngine.evaluate(expr, textOther)).toEqual({ ok: true, value: false }); + } + }); + + it('the overload retry runs its own rewritten program, row by row', () => { + const expr = { dialect: 'cel' as const, source: 'record.rating >= 4' }; + for (let i = 0; i < 3; i++) { + expect(celEngine.evaluate(expr, { record: { rating: '5.0' } })).toEqual({ ok: true, value: true }); + expect(celEngine.evaluate(expr, { record: { rating: 3 } })).toEqual({ ok: true, value: false }); + expect(celEngine.evaluate(expr, { record: { rating: '3.5' } })).toEqual({ ok: true, value: false }); + } + }); + + it.each([ + ['a parse fault', 'record.a +'], + ['a check fault', "1 + 'a'"], + ['a runtime fault', 'record.a.b.c'], + ['an unknown function', 'nope(record.a)'], + ])('%s is reported the same on every evaluation', (_label, source) => { + const expr = { dialect: 'cel' as const, source }; + const first = celEngine.evaluate(expr, { record: { a: 1 } }); + expect(first.ok).toBe(false); + for (let i = 0; i < 3; i++) expect(celEngine.evaluate(expr, { record: { a: 1 } })).toEqual(first); + }); +}); From 826ce6ae371b33e2a397cab91bca14eb8edd089b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:13:57 +0000 Subject: [PATCH 4/5] chore(changeset): formula CEL environment reuse Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude --- .changeset/24536-cel-env-reuse.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/24536-cel-env-reuse.md diff --git a/.changeset/24536-cel-env-reuse.md b/.changeset/24536-cel-env-reuse.md new file mode 100644 index 00000000000..ff9b405fcd9 --- /dev/null +++ b/.changeset/24536-cel-env-reuse.md @@ -0,0 +1,12 @@ +--- +'@objectstack/formula': patch +--- + +perf(formula): a CEL evaluation reuses one environment instead of building the stdlib on every call, and compiles reuse the canonical parse environment + +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) + +- **`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. +- **`celEngine.compile` reuses the canonical parse environment** that `parseCelToAst` already shares. +- Results, error kinds and error messages are unchanged. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures and their behaviour. +- 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. From 95f434429ab0071ec19d6e1b000e25dedc5a88a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:27:56 +0000 Subject: [PATCH 5/5] fix(formula): a cached CEL program serves one run at a time cel-js keeps a comprehension's and cel.bind's overlay context, and a call's runtime argTypes, on the AST. A run re-entered from inside its own comprehension could re-parent the outer run's overlay and read the inner run's scope. A run that finds its program already running now parses a private one. Claude-Session: https://claude.ai/code/session_01QNSdgij7mWezAKUbkzSmmR Co-authored-by: Claude --- packages/formula/src/cel-engine.ts | 48 ++++++++++- .../formula/src/cel-program-reentry.test.ts | 81 +++++++++++++++++++ packages/formula/src/unknown-function.ts | 2 +- 3 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 packages/formula/src/cel-program-reentry.test.ts diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 5c934212c2f..021ae582c7b 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -104,6 +104,11 @@ const UNBOUND: StdLibBinding = { now: () => new Date(), timezone: 'UTC', permiss * `celEngine.evaluate` saves it, sets its own and restores the saved one in a * `finally`, so a nested evaluation and a thrown one both leave the outer * binding exactly as it was, and no binding outlives its evaluation. + * + * Every evaluation runs to completion inside `evaluate`: cel-js goes + * asynchronous only for a function registered as async, and this environment + * registers none. A Promise-valued scope value is refused (`Unsupported type: + * Promise`, a `runtime` fault), so no continuation ever reads the slot later. */ let activeBinding: StdLibBinding = UNBOUND; @@ -127,9 +132,28 @@ let evaluationEnv: Environment | undefined; * * With the environment built once, parsing and type-checking were most of what * an evaluation still cost, and one source is evaluated once per row. A cel-js - * program is the parse-once, evaluate-many form of `env.evaluate`: it holds the - * AST and the types its check recorded, never a value, so it answers every - * scope exactly as a fresh parse would. + * program is the parse-once, evaluate-many form of `env.evaluate`. + * + * ⚠️ A program is NOT free of evaluation state. cel-js keeps per-run state on + * the AST itself: + * - a comprehension (`exists`, `all`, `exists_one`, `map`, `filter`) keeps + * its iteration context on the AST (`args.iterCtx`), and `cel.bind` keeps + * its binding context there too (`bindCtx`). Each run re-parents that + * overlay to its own scope (`OverlayContext.reuse`), and every variable the + * step reads goes through that parent; + * - a function call writes its arguments' RUNTIME types into the AST's + * `argTypes` on every call, and then reads them back to pick an overload. + * + * In sequential use each of these is overwritten before it is read, so a + * cached program answers exactly as a fresh parse would. Two runs of ONE AST + * at once do not: a run re-entered from inside its own comprehension (a scope + * getter that evaluates the same source for another subject) would re-parent + * the outer run's overlay, and the outer run would read the inner run's + * `current_user` and `record`. So a program serves one run at a time + * ({@link runningPrograms}). 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 size. * * The key is the source AFTER the rewrites (`rewriteTemporalEquality`, * `rewriteNullableTernary`, the overload retry's `rewriteFaultedOperands`), so a @@ -142,6 +166,15 @@ let evaluationEnv: Environment | undefined; const evaluationPrograms = new Map>(); const EVALUATION_PROGRAM_CACHE_MAX = 500; +/** + * The programs with a run in progress. A run that finds its source's cached + * program here, which is a nested evaluation of the same source, parses a + * private one instead, so no AST is ever run twice at once. Runs are + * synchronous (see {@link activeBinding}), so a run is over when the program + * returns or throws. + */ +const runningPrograms = new Set>(); + /** Evaluate `source` on {@link evaluationEnv} through its cached program. */ function runProgram(env: Environment, source: string, scope: Record): unknown { let program = evaluationPrograms.get(source); @@ -152,8 +185,15 @@ function runProgram(env: Environment, source: string, scope: Record { + return celEngine.evaluate({ dialect: 'cel', source }, ctx); +} + +/** What a fresh parse on a fresh environment answers, with the same scope. */ +function freshAnswer(source: string, ctx: EvalContext): unknown { + return buildEnv(() => new Date(0)).evaluate(source, buildScope(ctx)); +} + +/** + * A value whose `probe` getter first evaluates `source` for BOB, on a record + * that does not nest again, and then reads true. + */ +function probing(source: string, bobRecord: Record): Record { + return Object.defineProperty({}, 'probe', { + enumerable: true, + get: () => { + evaluate(source, { user: BOB, record: bobRecord }); + return true; + }, + }); +} + +describe('#24536 — the same source re-entered from inside its own run', () => { + it('inside exists(…): the outer step still reads the outer subject', () => { + const source = "record.items.exists(i, i.probe && current_user.id == 'usr_alice')"; + const bobRecord = { items: [{ probe: true }] }; + const ctx = (): EvalContext => ({ user: ALICE, record: { items: [probing(source, bobRecord)] } }); + expect(freshAnswer(source, ctx())).toBe(true); + // Warm the cache first, so the nested run meets a cached program. + expect(evaluate(source, { user: ALICE, record: bobRecord })).toEqual({ ok: true, value: true }); + for (let i = 0; i < 3; i++) expect(evaluate(source, ctx())).toEqual({ ok: true, value: true }); + }); + + it('inside map(…): every element is computed for the outer subject', () => { + const source = 'record.items.map(i, i.probe ? current_user.id : "")'; + const bobRecord = { items: [{ probe: true }] }; + const ctx = (): EvalContext => ({ user: ALICE, record: { items: [probing(source, bobRecord), probing(source, bobRecord)] } }); + expect(freshAnswer(source, ctx())).toEqual(['usr_alice', 'usr_alice']); + evaluate(source, { user: ALICE, record: bobRecord }); + expect(evaluate(source, ctx())).toEqual({ ok: true, value: ['usr_alice', 'usr_alice'] }); + }); + + it('inside cel.bind(…): the bound expression still reads the outer subject', () => { + const source = "cel.bind(p, record.box.probe, p && current_user.id == 'usr_alice')"; + const bobRecord = { box: { probe: true } }; + const ctx = (): EvalContext => ({ user: ALICE, record: { box: probing(source, bobRecord) } }); + expect(freshAnswer(source, ctx())).toBe(true); + expect(evaluate(source, { user: ALICE, record: bobRecord })).toEqual({ ok: true, value: true }); + for (let i = 0; i < 3; i++) expect(evaluate(source, ctx())).toEqual({ ok: true, value: true }); + }); + + it('the inner run answers for its own subject too (BOB is not ALICE)', () => { + const source = "record.items.exists(i, i.probe && current_user.id == 'usr_alice')"; + expect(evaluate(source, { user: BOB, record: { items: [{ probe: true }] } })).toEqual({ ok: true, value: false }); + }); +}); diff --git a/packages/formula/src/unknown-function.ts b/packages/formula/src/unknown-function.ts index 7a7b115fcb2..b4dd7c72856 100644 --- a/packages/formula/src/unknown-function.ts +++ b/packages/formula/src/unknown-function.ts @@ -73,7 +73,7 @@ * ## Why refusing an unregistered call is not a false positive * * The validation environment and the runtime environment are the same builder — - * `celEngine.compile` and `celEngine.evaluate` both call {@link buildEnv}, and + * `celEngine.compile` and `celEngine.evaluate` both build through {@link buildEnv}'s constructor, and * the census on #13594 probed 53 names across both with **0 divergent verdicts** * (and found **0** host-registered extra functions in the reachable corpus). So * a call this refuses is a call that WILL fault at evaluation time; refusing it