diff --git a/.changeset/formula-plan-eval-context.md b/.changeset/formula-plan-eval-context.md new file mode 100644 index 0000000000..210d161e9b --- /dev/null +++ b/.changeset/formula-plan-eval-context.md @@ -0,0 +1,11 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): thread execution context into read-time formula evaluation + +`applyFormulaPlan` — which computes `Field.formula` virtual fields after a `find`/`findOne` — evaluated each expression with only `{ record }`. So a formula using `now()`/`today()` ran against a fresh wall-clock read on every evaluation (no determinism), and a formula referencing the caller (`os.user.id`, `os.org.id`) faulted and fell back to `null` because the user/org were never in scope. + +It now builds the eval context the same way `applyFieldDefaults` already does: a `now` snapshot **pinned once per operation** (every row and every formula field in one read observes the same instant) plus `os.user` / `os.org` resolved from the `ExecutionContext`. Read-time formulas behave consistently with default-value expressions, and computed fields can reference the caller. + +This is independent of timezone; it is the read-path prerequisite for ADR-0053 Phase 2 (#1980 will additionally thread `timezone` here once `ExecutionContext.timezone` exists). diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 6069a6eaa3..66ec4fb535 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -511,6 +511,65 @@ describe('ObjectQL Engine', () => { expect(result[0].response_rate).toBe(70); }); + it('evaluates read-time formula fields with execution-context user/org (#1979)', async () => { + // Regression: applyFormulaPlan used to pass only `{ record }`, so a + // computed field referencing the caller (`os.user.id` / `os.org.id`) + // faulted and fell back to null. The context now threads through. + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ + name: 'memo', + fields: { + id: { type: 'text' }, + body: { type: 'text' }, + author_ref: { + type: 'formula', + expression: { dialect: 'cel', source: 'os.user.id' }, + }, + org_ref: { + type: 'formula', + expression: { dialect: 'cel', source: 'os.org.id' }, + }, + }, + } as any); + + vi.mocked(mockDriver.find).mockResolvedValueOnce([ + { id: 'm1', body: 'hello' }, + { id: 'm2', body: 'world' }, + ]); + + const result = await engine.find( + 'memo', + { fields: ['id', 'author_ref', 'org_ref'] } as any, + { context: { userId: 'u-42', tenantId: 'org-7', roles: ['admin'] } as any }, + ); + + expect(result.map((r: any) => r.author_ref)).toEqual(['u-42', 'u-42']); + expect(result.map((r: any) => r.org_ref)).toEqual(['org-7', 'org-7']); + }); + + it('pins `now` once per find so every row sees the same instant (#1979)', async () => { + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ + name: 'ping', + fields: { + id: { type: 'text' }, + ts: { + type: 'formula', + expression: { dialect: 'cel', source: 'now()' }, + }, + }, + } as any); + + vi.mocked(mockDriver.find).mockResolvedValueOnce([ + { id: 'a' }, { id: 'b' }, { id: 'c' }, + ]); + + const result = await engine.find('ping', { fields: ['id', 'ts'] } as any); + + // Determinism: a single operation snapshots one `now`, shared across + // every row — not a fresh wall-clock read per evaluation. + expect(result[0].ts).toEqual(result[1].ts); + expect(result[1].ts).toEqual(result[2].ts); + }); + it('should handle null values gracefully during expand', async () => { vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => { if (name === 'task') return { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8f54bf34af..39142a709c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -85,12 +85,35 @@ function planFormulaProjection( return { plan }; } -function applyFormulaPlan(plan: FormulaPlanEntry[], records: any[]): void { +/** + * Evaluate read-time formula virtual fields against the raw rows. + * + * The eval context mirrors `applyFieldDefaults` so formula and default + * expressions see the same shape: a `now` pinned ONCE per operation (every row + * and every formula field in one `find()` observes the same instant — + * determinism, and no per-eval `new Date()` drift), plus `os.user` / `os.org` + * resolved from the execution context (so a computed field can reference the + * caller, e.g. `os.user.id`). Previously this passed only `{ record }`, so + * `now()`/`today()` ran against live wall-clock and user/org were unreachable. + * + * (ADR-0053 Phase 2 will additionally thread `timezone` here once + * `ExecutionContext.timezone` exists — see #1980; this change is independent + * of timezone.) + */ +function applyFormulaPlan( + plan: FormulaPlanEntry[], + records: any[], + execCtx?: ExecutionContext, + nowSnapshot?: Date, +): void { if (!plan.length) return; + const now = nowSnapshot ?? new Date(); + const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined; + const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined; for (const rec of records) { if (rec == null) continue; for (const fp of plan) { - const r = ExpressionEngine.evaluate(fp.expression, { record: rec }); + const r = ExpressionEngine.evaluate(fp.expression, { now, user, org, record: rec }); rec[fp.name] = r.ok ? r.value : null; } } @@ -1866,7 +1889,7 @@ export class ObjectQL implements IDataEngine { let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any); // Post-process: evaluate formula virtual fields against the raw rows - if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result); + if (Array.isArray(result)) applyFormulaPlan(_findFormula.plan, result, opCtx.context); // Post-process: expand related records if expand is requested if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) { @@ -1932,7 +1955,7 @@ export class ObjectQL implements IDataEngine { let result = await driver.findOne(objectName, opCtx.ast as QueryAST, findOneOpts); // Post-process: evaluate formula virtual fields against the raw row - if (result != null) applyFormulaPlan(_findOneFormula.plan, [result]); + if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context); // Post-process: expand related records if expand is requested if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {