Skip to content

Commit 41c18ca

Browse files
committed
fix(trigger-record-change): hydrate read-time formula fields onto the seeded flow record (#3426)
A `formula` field is a read-time virtual — evaluated post-fetch on `find`/`findOne`, never on the write path — so it was absent from the raw after-create/after-update row a record-change flow is seeded with. A notify node template like `{record.full_name}`, or a start condition on the same field, therefore resolved to an empty string, silently producing notifications such as `"New lead to assign: "` with the name missing. The record-change trigger now re-reads the just-written record through the data engine, so the seeded `record` carries the same computed fields a data-API read returns. The fix lives at the trigger — the producer of the flow's `record` — so it benefits the whole flow (start condition, every node, and notify `title`/`body` templates), not just the notify node. Conservative by design: - Only for `afterInsert` / `afterUpdate` (the row exists post-write); `before*` and `afterDelete` keep the raw hook record. - Reads as an elevated system principal, so it only ADDS computed fields and never lets RLS/FLS on the re-read shrink the snapshot the flow already saw. - Raw hook fields win on merge, preserving trigger-time scalars and the #1872 multi-lookup input overlay; the re-read only fills in the formula virtuals. - Any failure (no read surface, no id, a throw, an empty read) falls back to the raw record — hydration never breaks the flow it feeds. Lookup traversal (`{record.account.name}`) is intentionally not hydrated: a default data-API read does not expand relations either, and expanding would turn `record.account` from its scalar FK id into an object, breaking templates that use the bare id (e.g. #1872's `{record.target_channels.0}`). Adds seven focused unit tests plus an end-to-end integration test that boots a real ObjectQL + automation + record-change stack with a formula field and proves `{record.full_name}` resolves in a seeded flow record. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StfHcpZduPXA6zSAG6beFR
1 parent 2ba560a commit 41c18ca

4 files changed

Lines changed: 371 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/trigger-record-change": patch
3+
---
4+
5+
fix(trigger-record-change): hydrate read-time formula fields onto the seeded flow record (#3426)
6+
7+
A `formula` field is a read-time virtual — the engine evaluates it post-fetch on
8+
`find`/`findOne`, never on the write path — so it was absent from the raw
9+
after-create/after-update row a record-change flow is seeded with. A notify
10+
node template like `{record.full_name}` (or a start condition on the same field)
11+
therefore resolved to an empty string, silently emitting notifications such as
12+
`"New lead to assign: "` with the name missing.
13+
14+
The record-change trigger now re-reads the just-written record through the data
15+
engine, so the seeded `record` carries the same computed fields a data-API read
16+
returns. The fix is at the trigger (the producer of the flow's `record`), so it
17+
benefits the whole flow — start condition, every node, and notify `title`/`body`
18+
templates — not just the notify node.
19+
20+
Deliberately conservative:
21+
22+
- Runs only for `afterInsert` / `afterUpdate` (the row exists in its post-write
23+
state); `before*` and `afterDelete` keep the raw hook record untouched.
24+
- Reads as an elevated system principal, so it can only ADD computed fields,
25+
never let RLS/FLS on the re-read shrink the snapshot the flow already saw.
26+
- Raw hook fields win on merge, preserving trigger-time scalar values and the
27+
#1872 multi-lookup input overlay; the re-read only fills in keys the raw row
28+
lacks (the formula virtuals).
29+
- Any failure (no read surface, no id, a throw, an empty read) falls back to the
30+
raw record — hydration never breaks the flow it feeds.
31+
32+
Lookup **traversal** (`{record.account.name}`) is intentionally not hydrated: a
33+
default data-API read does not expand relations either, and expanding would turn
34+
`record.account` from its scalar FK id into an object, breaking templates and
35+
conditions that use the bare id (e.g. #1872's `{record.target_channels.0}`).
36+
That traversal remains tracked on #3426.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3426 — a `formula` field is a READ-time virtual: the engine evaluates it
5+
* post-fetch on `find`/`findOne`, never on the write path, so it is absent from
6+
* the raw after-create/after-update row a record-change flow is seeded with.
7+
* `{record.full_name}` in a notify template (or a start condition) therefore
8+
* resolved to an empty string. The trigger now re-reads the written record
9+
* through the data engine, so the seeded `record` carries the same computed
10+
* fields a data-API read returns.
11+
*
12+
* This exercises the whole stack (real ObjectQL + automation + record-change
13+
* trigger) with a formula field, proving the seeded record resolves it. The
14+
* notify node interpolates the very same variable map, so a formula that
15+
* resolves for `update_record` here resolves for a notify `title`/`body` too.
16+
*/
17+
import { describe, it, expect } from 'vitest';
18+
import { ObjectKernel } from '@objectstack/core';
19+
import { ObjectQLPlugin } from '@objectstack/objectql';
20+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
21+
import { RecordChangeTriggerPlugin } from './plugin.js';
22+
23+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
24+
25+
/** Memory driver storing full rows. Formula virtuals are computed by the
26+
* ENGINE post-fetch, never by the driver — so `full_name` is never stored. */
27+
function makeDriver(): any {
28+
const store = new Map<string, Record<string, unknown>>();
29+
let n = 0;
30+
const matches = (row: any, where: any): boolean => {
31+
if (!where || typeof where !== 'object') return true;
32+
for (const [k, v] of Object.entries(where)) {
33+
if (k.startsWith('$')) continue;
34+
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
35+
if ((row[k] ?? null) !== (exp ?? null)) return false;
36+
}
37+
return true;
38+
};
39+
return {
40+
name: 'memory', version: '0', supports: {},
41+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
42+
async execute() { return null; }, async syncSchema() {},
43+
async create(_o: string, data: any) {
44+
n += 1; const id = data.id ?? `r_${n}`;
45+
const full = { ...data, id };
46+
store.set(id, full);
47+
return { ...full };
48+
},
49+
async update(_o: string, id: string, data: any) { const cur = store.get(id) ?? {}; const u = { ...cur, ...data, id }; store.set(id, u); return { ...u }; },
50+
async find(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); },
51+
async findOne(_o: string, ast: any) { for (const r of store.values()) if (matches(r, ast?.where)) return { ...r }; return null; },
52+
async delete(_o: string, id: string) { return store.delete(id); },
53+
async count(_o: string, ast: any) { return (await this.find(_o, ast)).length; },
54+
async upsert(_o: string, d: any) { return this.create(_o, d); },
55+
async bulkCreate(_o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(_o, r))); },
56+
async bulkUpdate() { return []; }, async bulkDelete() {},
57+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
58+
async commit() {}, async rollback() {},
59+
};
60+
}
61+
62+
describe('record-change context hydrates read-time formula fields (#3426)', () => {
63+
it('resolves a formula field ({record.full_name}) in a seeded flow record', async () => {
64+
const kernel = new ObjectKernel({ logLevel: 'silent' });
65+
await kernel.use(new ObjectQLPlugin());
66+
await kernel.use(new AutomationServicePlugin());
67+
await kernel.use(new RecordChangeTriggerPlugin());
68+
await kernel.bootstrap();
69+
70+
const objectql = kernel.getService('objectql') as any;
71+
const data = kernel.getService('data') as any;
72+
const automation = kernel.getService<AutomationEngine>('automation');
73+
objectql.registerDriver(makeDriver(), true);
74+
objectql.registry.registerObject({
75+
name: 'crm_lead', label: 'Lead',
76+
fields: {
77+
first_name: { name: 'first_name', label: 'First', type: 'text' },
78+
last_name: { name: 'last_name', label: 'Last', type: 'text' },
79+
// Read-time formula virtual — never present on the raw written row.
80+
full_name: {
81+
name: 'full_name', label: 'Full name', type: 'formula',
82+
expression: { dialect: 'cel', source: 'record.first_name + " " + record.last_name' },
83+
},
84+
greeting: { name: 'greeting', label: 'Greeting', type: 'text' },
85+
},
86+
}, 'test', 'test');
87+
88+
// On create, stamp `greeting` from the formula field. Before the fix this
89+
// stamped '' because `{record.full_name}` was blank in the seeded record.
90+
automation.registerFlow('lead_greeting', {
91+
name: 'lead_greeting', label: 'Greeting', type: 'autolaunched',
92+
nodes: [
93+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'crm_lead', triggerType: 'record-after-create' } },
94+
{ id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { greeting: 'Hello, {record.full_name}!' } } },
95+
{ id: 'end', type: 'end', label: 'End' },
96+
],
97+
edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ],
98+
} as any);
99+
100+
const created = await data.insert('crm_lead', { first_name: 'Ada', last_name: 'Lovelace' });
101+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
102+
await sleep(200);
103+
const row = await data.findOne('crm_lead', { where: { id } });
104+
expect(row?.full_name).toBe('Ada Lovelace'); // read-path sanity
105+
expect(row?.greeting).toBe('Hello, Ada Lovelace!'); // flow saw the formula
106+
}, 15000);
107+
});

packages/triggers/trigger-record-change/src/record-change-trigger.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,148 @@ describe('RecordChangeTrigger', () => {
258258
});
259259
});
260260

261+
// ─── computed-field hydration (#3426) ───────────────────────────────
262+
263+
describe('RecordChangeTrigger computed-field hydration (#3426)', () => {
264+
interface FindOneCall {
265+
object: string;
266+
options: { where?: Record<string, unknown>; fields?: string[]; context?: unknown };
267+
}
268+
269+
/** fakeEngine + a `findOne` that records its calls and returns `row`. */
270+
function fakeEngineWithRead(row: Record<string, unknown> | null | undefined) {
271+
const base = fakeEngine();
272+
const calls: FindOneCall[] = [];
273+
const engine: RecordChangeDataEngine = {
274+
...base.engine,
275+
async findOne(object, options) {
276+
calls.push({ object, options });
277+
return row;
278+
},
279+
};
280+
return { engine, hooks: base.hooks, calls };
281+
}
282+
283+
it('hydrates a formula field the raw hook row lacks (after-create)', async () => {
284+
// The re-read returns the formula virtual `full_name` (absent from the
285+
// written row) plus a field only the read path carries. After the merge
286+
// the flow sees the formula, and raw scalars still win.
287+
const { engine, hooks, calls } = fakeEngineWithRead({
288+
id: 'r1',
289+
first_name: 'Ada',
290+
last_name: 'Lovelace',
291+
full_name: 'Ada Lovelace',
292+
company: 'Analytical Engines',
293+
});
294+
const trigger = new RecordChangeTrigger(engine, silentLogger());
295+
let captured: AutomationContext | undefined;
296+
297+
trigger.start(
298+
binding({ object: 'crm_lead', event: 'record-after-create' }),
299+
async (ctx) => { captured = ctx; },
300+
);
301+
await hooks[0].handler(
302+
hookCtx({ event: 'afterInsert', result: { id: 'r1', first_name: 'Ada', last_name: 'Lovelace' } }),
303+
);
304+
305+
// Formula virtual is now resolvable on the seeded record…
306+
expect((captured?.record as Record<string, unknown>).full_name).toBe('Ada Lovelace');
307+
expect((captured?.record as Record<string, unknown>).company).toBe('Analytical Engines');
308+
// …and params mirrors the same hydrated record.
309+
expect((captured?.params as Record<string, unknown>)?.full_name).toBe('Ada Lovelace');
310+
// Re-read was a system-elevated findOne scoped to the written row.
311+
expect(calls).toHaveLength(1);
312+
expect(calls[0].object).toBe('crm_lead');
313+
expect(calls[0].options.where).toEqual({ id: 'r1' });
314+
expect((calls[0].options.context as { isSystem?: boolean }).isSystem).toBe(true);
315+
});
316+
317+
it('lets raw hook fields win over the re-read (trigger-time fidelity + #1872)', async () => {
318+
// A concurrent read could observe a newer scalar or drop a multi-lookup
319+
// array the driver echoed into the raw row; the raw value must survive.
320+
const { engine, hooks } = fakeEngineWithRead({
321+
id: 'r1',
322+
status: 'stale',
323+
target_channels: undefined,
324+
full_name: 'Ada Lovelace',
325+
});
326+
const trigger = new RecordChangeTrigger(engine, silentLogger());
327+
let captured: AutomationContext | undefined;
328+
329+
trigger.start(binding({ object: 'crm_lead', event: 'record-after-update' }), async (ctx) => { captured = ctx; });
330+
await hooks[0].handler(
331+
hookCtx({ event: 'afterUpdate', result: { id: 'r1', status: 'fresh', target_channels: ['ch_1'] } }),
332+
);
333+
334+
const rec = captured?.record as Record<string, unknown>;
335+
expect(rec.status).toBe('fresh'); // raw wins
336+
expect(rec.target_channels).toEqual(['ch_1']); // #1872 array preserved
337+
expect(rec.full_name).toBe('Ada Lovelace'); // formula added
338+
});
339+
340+
it('does not re-read for before-* events (row not yet persisted)', async () => {
341+
const { engine, hooks, calls } = fakeEngineWithRead({ id: 'r1', full_name: 'x' });
342+
const trigger = new RecordChangeTrigger(engine, silentLogger());
343+
344+
trigger.start(binding({ object: 'crm_lead', event: 'record-before-update' }), async () => {});
345+
await hooks[0].handler(hookCtx({ event: 'beforeUpdate', result: { id: 'r1' } }));
346+
347+
expect(calls).toHaveLength(0);
348+
});
349+
350+
it('does not re-read for after-delete (row is gone)', async () => {
351+
const { engine, hooks, calls } = fakeEngineWithRead({ id: 'r1', full_name: 'x' });
352+
const trigger = new RecordChangeTrigger(engine, silentLogger());
353+
354+
trigger.start(binding({ object: 'crm_lead', event: 'record-after-delete' }), async () => {});
355+
await hooks[0].handler(hookCtx({ event: 'afterDelete', result: { id: 'r1' } }));
356+
357+
expect(calls).toHaveLength(0);
358+
});
359+
360+
it('does not re-read when the record has no id', async () => {
361+
const { engine, hooks, calls } = fakeEngineWithRead({ id: 'r1', full_name: 'x' });
362+
const trigger = new RecordChangeTrigger(engine, silentLogger());
363+
let captured: AutomationContext | undefined;
364+
365+
trigger.start(binding({ object: 'crm_lead', event: 'record-after-create' }), async (ctx) => { captured = ctx; });
366+
await hooks[0].handler(hookCtx({ event: 'afterInsert', result: { first_name: 'Ada' } }));
367+
368+
expect(calls).toHaveLength(0);
369+
expect((captured?.record as Record<string, unknown>).first_name).toBe('Ada');
370+
});
371+
372+
it('falls back to the raw record when the re-read throws', async () => {
373+
const base = fakeEngine();
374+
const engine: RecordChangeDataEngine = {
375+
...base.engine,
376+
async findOne() { throw new Error('db down'); },
377+
};
378+
const debug = vi.fn();
379+
const trigger = new RecordChangeTrigger(engine, { info: () => {}, warn: () => {}, debug });
380+
let captured: AutomationContext | undefined;
381+
382+
trigger.start(binding({ object: 'crm_lead', event: 'record-after-create' }), async (ctx) => { captured = ctx; });
383+
await base.hooks[0].handler(hookCtx({ event: 'afterInsert', result: { id: 'r1', first_name: 'Ada' } }));
384+
385+
// Flow still runs with the raw record; the failure is a debug note only.
386+
expect((captured?.record as Record<string, unknown>).first_name).toBe('Ada');
387+
expect((captured?.record as Record<string, unknown>).full_name).toBeUndefined();
388+
expect(debug).toHaveBeenCalled();
389+
});
390+
391+
it('is a no-op on engines with no findOne surface (older cores)', async () => {
392+
const { engine, hooks } = fakeEngine(); // no findOne
393+
const trigger = new RecordChangeTrigger(engine, silentLogger());
394+
let captured: AutomationContext | undefined;
395+
396+
trigger.start(binding({ object: 'crm_lead', event: 'record-after-create' }), async (ctx) => { captured = ctx; });
397+
await hooks[0].handler(hookCtx({ event: 'afterInsert', result: { id: 'r1', first_name: 'Ada' } }));
398+
399+
expect((captured?.record as Record<string, unknown>).first_name).toBe('Ada');
400+
});
401+
});
402+
261403
// ─── RecordChangeTriggerPlugin ──────────────────────────────────────
262404

263405
describe('RecordChangeTriggerPlugin', () => {

0 commit comments

Comments
 (0)