Skip to content

Commit a6d4cbb

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(automation,objectql): conditional & record-change flows silently skipping (#1429)
Every flow with a start-node / edge condition silently skipped — record-change triggers fired but the flow body never ran, and `previous.*` / `budget > N` gates all evaluated false. Two independent bugs: service-automation — CEL engine unreachable in ESM The condition evaluator loaded the formula engine via a CommonJS `require('@objectstack/formula')`. In the package's ESM build ("type":"module") that compiles to tsup's throwing `__require` stub, so every CEL evaluation threw and the swallowing catch returned false. Replaced with a static top-level import — binds correctly in both ESM and CJS builds. objectql — prior record not exposed to update hooks HookContext documents a `previous` snapshot for update/delete, but engine.update never populated it (the row fetched for validation stayed a local var). Record-change conditions like `status == "done" && previous.status != "done"` had no `previous` to read. The engine now attaches the pre-update record to hookContext.previous for single-id updates when a validation rule needs it or an afterUpdate hook is registered. Verified end-to-end in the showcase: reassign → notify→inbox, mark-done → REST/Slack connector_action + email, budget>100k → approval (paused at manager_review). New unit tests cover both paths. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 679311a commit a6d4cbb

5 files changed

Lines changed: 98 additions & 9 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
Fix conditional & record-change flows silently skipping.
7+
8+
Two bugs together caused every flow with a start-node / edge **condition** to
9+
silently skip (record-change triggers fired but the flow body never ran;
10+
audit-style `previous.*` gates and `budget > 100000`-style gates all evaluated
11+
to false):
12+
13+
- **service-automation — CEL engine unreachable in ESM.** The condition
14+
evaluator loaded the formula engine via a CommonJS `require('@objectstack/formula')`.
15+
In the package's ESM build (`"type": "module"`) that resolves to tsup's
16+
throwing `__require` stub, so **every** CEL evaluation threw and the
17+
swallowing `catch` returned `false`. Replaced with a static top-level import,
18+
which binds correctly in both the ESM and CJS builds.
19+
20+
- **objectql — prior record not exposed to update hooks.** `HookContext`
21+
documents a `previous` snapshot for update/delete, but `engine.update` never
22+
populated it (the row it fetched for validation was a local var). Record-change
23+
conditions like `status == "done" && previous.status != "done"` therefore had
24+
no `previous` to read. The engine now attaches the pre-update record to
25+
`hookContext.previous` for single-id updates whenever a validation rule needs
26+
it or an `afterUpdate` hook is registered.
27+
28+
Both paths are covered by new unit tests.

packages/objectql/src/engine.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,36 @@ describe('ObjectQL Engine', () => {
245245
});
246246
});
247247

248+
describe('Update hooks — previous-record snapshot', () => {
249+
beforeEach(async () => {
250+
engine.registerDriver(mockDriver, true);
251+
await engine.init();
252+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any);
253+
});
254+
255+
it('attaches the pre-update record to afterUpdate ctx.previous when an afterUpdate hook is registered', async () => {
256+
// Regression: record-change flow triggers read `previous.*` in their
257+
// start condition (e.g. `status == "done" && previous.status != "done"`).
258+
// The engine must expose the pre-update row on the hook context.
259+
vi.mocked(mockDriver.findOne).mockResolvedValue({ id: 't1', status: 'in_review', assignee: 'sam@example.com' });
260+
vi.mocked(mockDriver.update).mockResolvedValue({ id: 't1', status: 'done', assignee: 'sam@example.com' } as any);
261+
262+
let captured: any = 'UNSET';
263+
engine.registerHook('afterUpdate', async (ctx: any) => { captured = ctx.previous; }, { packageId: 'test' } as any);
264+
265+
await engine.update('task', { id: 't1', status: 'done' });
266+
267+
expect(mockDriver.findOne).toHaveBeenCalled();
268+
expect(captured).toEqual({ id: 't1', status: 'in_review', assignee: 'sam@example.com' });
269+
});
270+
271+
it('does not fetch the prior record when no afterUpdate hook is registered and no rule needs it', async () => {
272+
vi.mocked(mockDriver.update).mockResolvedValue({ id: 't1' } as any);
273+
await engine.update('task', { id: 't1', status: 'done' });
274+
expect(mockDriver.findOne).not.toHaveBeenCalled();
275+
});
276+
});
277+
248278
describe('Expand Related Records', () => {
249279
beforeEach(async () => {
250280
engine.registerDriver(mockDriver, true);

packages/objectql/src/engine.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,19 +1850,25 @@ export class ObjectQL implements IDataEngine {
18501850

18511851
try {
18521852
let result;
1853+
// Pre-update snapshot. Exposed to after-hooks via `hookContext.previous`
1854+
// (the HookContext contract documents `previous` for update/delete) and
1855+
// reused for object-level validation rules. Fetched once, only for
1856+
// single-id updates, when either a rule needs it (ADR-0020:
1857+
// state_machine / cross_field / script — a PATCH carries only changed
1858+
// fields) OR an afterUpdate hook is registered. The latter is what makes
1859+
// record-change flow triggers work: their start-condition gate reads
1860+
// `previous.*` (e.g. `status == "done" && previous.status != "done"`),
1861+
// which silently fails when `previous` is absent.
1862+
let priorRecord: Record<string, unknown> | null = null;
18531863
const updateSchema = this._registry.getObject(object);
18541864
if (hookContext.input.id) {
18551865
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
18561866
validateRecord(updateSchema, hookContext.input.data as Record<string, unknown>, 'update');
1857-
// ADR-0020: object-level rules (state_machine / cross_field /
1858-
// script) need the prior record — a PATCH carries only changed
1859-
// fields. Fetch it once, only when such a rule is declared.
1860-
let previous: Record<string, unknown> | null = null;
1861-
if (needsPriorRecord(updateSchema as any)) {
1867+
if (needsPriorRecord(updateSchema as any) || (this.hooks.get('afterUpdate')?.length ?? 0) > 0) {
18621868
const priorAst: QueryAST = { object, where: { id: hookContext.input.id }, limit: 1 };
1863-
previous = await driver.findOne(object, priorAst, hookContext.input.options as any);
1869+
priorRecord = await driver.findOne(object, priorAst, hookContext.input.options as any);
18641870
}
1865-
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous, logger: this.logger });
1871+
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger });
18661872
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
18671873
} else if (options?.multi && driver.updateMany) {
18681874
await this.encryptSecretFields(object, hookContext.input.data as Record<string, unknown>, opCtx.context, hookContext.input.options);
@@ -1881,6 +1887,7 @@ export class ObjectQL implements IDataEngine {
18811887

18821888
hookContext.event = 'afterUpdate';
18831889
hookContext.result = result;
1890+
if (priorRecord) hookContext.previous = priorRecord;
18841891
await this.triggerHooks('afterUpdate', hookContext);
18851892

18861893
// Publish data.record.updated event to realtime service

packages/services/service-automation/src/engine.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,25 @@ describe('AutomationEngine - Safe Expression Evaluation', () => {
13321332
expect(engine.evaluateCondition('false', vars)).toBe(false);
13331333
});
13341334

1335+
it('evaluates CEL record-change conditions with bare fields and previous.* snapshot', () => {
1336+
// The record-change trigger shape: the new record's fields are seeded at
1337+
// top level, plus a `previous` snapshot. Regression guard — a broken
1338+
// ExpressionEngine binding (e.g. an ESM `require` stub) made the CEL path
1339+
// throw and silently return false, skipping every record-change flow.
1340+
const vars = new Map<string, unknown>();
1341+
vars.set('status', 'done');
1342+
vars.set('assignee', 'newowner@example.com');
1343+
vars.set('previous', { status: 'in_review', assignee: 'ada@example.com' });
1344+
1345+
expect(engine.evaluateCondition({ dialect: 'cel', source: 'status == "done" && previous.status != "done"' }, vars)).toBe(true);
1346+
expect(engine.evaluateCondition({ dialect: 'cel', source: 'assignee != previous.assignee' }, vars)).toBe(true);
1347+
1348+
const unchanged = new Map<string, unknown>();
1349+
unchanged.set('status', 'done');
1350+
unchanged.set('previous', { status: 'done' });
1351+
expect(engine.evaluateCondition({ dialect: 'cel', source: 'status == "done" && previous.status != "done"' }, unchanged)).toBe(false);
1352+
});
1353+
13351354
it('should not execute malicious code', () => {
13361355
const vars = new Map<string, unknown>();
13371356
// These should all return false safely

packages/services/service-automation/src/engine.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import type { Logger } from '@objectstack/spec/contracts';
77
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from '@objectstack/spec/automation';
88
import type { Connector } from '@objectstack/spec/integration';
99
import { ConnectorSchema } from '@objectstack/spec/integration';
10+
// Static import (not a lazy `require`): the engine ships as ESM ("type":"module"),
11+
// where a CommonJS `require('@objectstack/formula')` resolves to tsup's throwing
12+
// `__require` stub. That threw on every CEL evaluation and the catch below
13+
// silently returned `false`, so EVERY start-node / edge condition (record-change
14+
// `previous.*`, `budget > 100000`, …) skipped its flow. A static import binds the
15+
// engine at module load in both ESM and CJS builds.
16+
import { ExpressionEngine } from '@objectstack/formula';
1017

1118
// ─── Node Executor Interface (Plugin Extension Point) ───────────────
1219

@@ -1287,8 +1294,6 @@ export class AutomationEngine implements IAutomationService {
12871294
// the equivalent `vars.step.result` CEL identifier path.
12881295
if (dialect === 'cel' || (isEnvelope && !dialect)) {
12891296
try {
1290-
// eslint-disable-next-line @typescript-eslint/no-require-imports
1291-
const { ExpressionEngine } = require('@objectstack/formula') as typeof import('@objectstack/formula');
12921297
const vars: Record<string, unknown> = {};
12931298
for (const [key, value] of variables) {
12941299
// Convert "step.result" keys into nested object paths.

0 commit comments

Comments
 (0)