Skip to content

Commit 6f55c63

Browse files
os-zhuangclaude
andauthored
feat(trigger-record-change): record-after-write fires one flow on create OR update (#3427) (#3446)
A record_change flow's start node bound to exactly one lifecycle event via `triggerType`, so a rule meant to run on both insert and update forced authors to duplicate the whole flow into two near-identical definitions that drift. Add `record-after-write` / `record-before-write` — the create-OR-update union (delete excluded: a write persists field data, a delete removes the row). One start node binds both lifecycle hooks (afterInsert + afterUpdate) under the same flow; exactly one fires per mutation (a write is an insert xor an update), so it is not a double run. To branch on which event fired, test `previous` (empty on create, populated on update). - New `triggerTypeToHookEvents` (plural) is the canonical mapper, expanding `write` to both events; `triggerTypeToHookEvent` (singular) is kept for back-compat and returns null for the multi-event write tokens. - The engine already forwards any `record-*` token to this trigger, so no engine/lint/spec change is needed — the trigger owns the vocabulary. - Unit + end-to-end tests (a single write flow fires on both create and update, self-write does not loop); docs under Automation > Flows and the README. Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1dc94f0 commit 6f55c63

8 files changed

Lines changed: 298 additions & 25 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/trigger-record-change": minor
3+
---
4+
5+
feat(trigger-record-change): `record-after-write` fires one flow on create OR update (#3427)
6+
7+
A `record_change` flow's `start` node bound to exactly one lifecycle event via
8+
`triggerType`, so a rule meant to run on both insert and update ("recompute the
9+
SLA whenever a case is created or its priority changes") forced authors to
10+
duplicate the whole flow — two near-identical definitions that drift.
11+
12+
Adds `record-after-write` and `record-before-write` as the **create-OR-update
13+
union** trigger tokens. One `start` node binds both lifecycle hooks
14+
(`afterInsert` + `afterUpdate`) under the same flow; exactly one fires per
15+
mutation (a write is an insert *xor* an update), so it is not a double run.
16+
`delete` is deliberately excluded — a write persists field data, a delete
17+
removes the row. To branch on which event fired inside the flow, test
18+
`previous` (empty on create, populated on update).
19+
20+
- `triggerTypeToHookEvents(triggerType)` (new, plural) is the canonical mapper:
21+
it returns the list of hook events a token binds, expanding `write` to both.
22+
`triggerTypeToHookEvent` (singular) is kept for back-compat and now returns
23+
`null` for the multi-event `write` tokens rather than silently dropping a
24+
binding.
25+
- The engine already forwards any `record-*` token through to this trigger, so
26+
no engine, lint, or spec change is needed — the trigger owns the vocabulary.
27+
28+
Documented under Automation › Flows (Create-or-update flow) and the trigger's
29+
README.

content/docs/automation/flows.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,10 +816,55 @@ flows: [
816816
],
817817
```
818818

819+
### Create-or-update flow (`record-after-write`)
820+
821+
A `record_change` start node binds to lifecycle events through `triggerType`.
822+
The single-event tokens map one-to-one:
823+
824+
| `triggerType` | Fires on |
825+
| :--------------------- | :------------------------------ |
826+
| `record-after-create` | insert (after) |
827+
| `record-after-update` | update (after) |
828+
| `record-after-delete` | delete (after) |
829+
| `record-after-write` | **insert *or* update (after)** |
830+
| `record-before-create` | insert (before) |
831+
| `record-before-update` | update (before) |
832+
| `record-before-delete` | delete (before) |
833+
| `record-before-write` | **insert *or* update (before)** |
834+
835+
For a rule that must run on **both create and update** — "recompute the SLA
836+
whenever a case is created or its priority changes" — use `record-after-write`
837+
instead of duplicating the flow. `write` is the create-OR-update union (delete
838+
is excluded: a write persists field data, a delete removes the row). One `start`
839+
node binds both lifecycle events; exactly one fires per mutation, so it is not a
840+
double run.
841+
842+
```typescript
843+
{
844+
id: 'start',
845+
type: 'start',
846+
label: 'Start',
847+
config: {
848+
objectName: 'crm_case',
849+
triggerType: 'record-after-write', // created OR updated
850+
},
851+
}
852+
```
853+
854+
To branch on **which** event fired, test `previous` — it is empty on create
855+
(there was no prior row) and populated on update:
856+
857+
```typescript
858+
// Edge conditions are bare CEL (ADR-0032) — no {…} braces.
859+
{ id: 'e_created', source: 'start', target: 'on_create', condition: 'previous == null' },
860+
{ id: 'e_updated', source: 'start', target: 'on_update', condition: 'previous != null' },
861+
```
862+
819863
### Best practices
820864

821865
**DO:**
822866
- Keep flows simple and focused
867+
- Prefer one `record-after-write` flow over two near-identical create/update copies
823868
- Document the business logic
824869
- Test recursion and retry behavior
825870
- Use scheduled flows for batch updates

content/docs/getting-started/common-patterns.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ export const crm = defineApp({
242242
Create an automation that fires when a record changes. The **start node's `config`
243243
binds the trigger**: `objectName` plus one lifecycle event in `triggerType`
244244
(`record-after-create`, `record-after-update`, `record-after-delete`, or the
245-
`record-before-*` forms) — without it the flow never fires.
245+
`record-before-*` forms) — without it the flow never fires. To fire on **create
246+
or update in one flow**, use `record-after-write` (the create-OR-update union)
247+
instead of duplicating the definition — see
248+
[Flows › Create-or-update flow](/docs/automation/flows#create-or-update-flow-record-after-write).
246249

247250
```typescript
248251
import { defineFlow } from '@objectstack/spec';

packages/triggers/trigger-record-change/README.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,43 @@ node gate) before running the flow, with `record` (the new row) and `previous`
3131

3232
### Trigger event → hook mapping
3333

34-
| `triggerType` | ObjectQL hook |
35-
| ------------------------ | -------------- |
36-
| `record-after-create` | `afterInsert` |
37-
| `record-after-update` | `afterUpdate` |
38-
| `record-after-delete` | `afterDelete` |
39-
| `record-before-create` | `beforeInsert` |
40-
| `record-before-update` | `beforeUpdate` |
41-
| `record-before-delete` | `beforeDelete` |
34+
| `triggerType` | ObjectQL hook(s) |
35+
| ------------------------ | ----------------------------- |
36+
| `record-after-create` | `afterInsert` |
37+
| `record-after-update` | `afterUpdate` |
38+
| `record-after-delete` | `afterDelete` |
39+
| `record-after-write` | `afterInsert` + `afterUpdate` |
40+
| `record-before-create` | `beforeInsert` |
41+
| `record-before-update` | `beforeUpdate` |
42+
| `record-before-delete` | `beforeDelete` |
43+
| `record-before-write` | `beforeInsert` + `beforeUpdate` |
44+
45+
`record-after-create` / `record-after-insert` are synonyms (both → `afterInsert`).
46+
47+
### Create **or** update in one flow: `record-*-write`
48+
49+
`record-after-write` (and its before-phase form) is the **create-OR-update
50+
union** — one `start` node that fires on both insert and update, so a
51+
"recompute whenever a record is created or changed" rule needs **one** flow, not
52+
two near-identical copies. It binds both lifecycle hooks under the same flow;
53+
exactly one fires per mutation (a write is an insert *xor* an update), so it is
54+
not a double-dispatch. `delete` is deliberately excluded — a write persists
55+
field data, a delete removes the row.
56+
57+
```ts
58+
{
59+
type: 'start',
60+
config: {
61+
objectName: 'crm_case',
62+
triggerType: 'record-after-write', // created OR updated
63+
},
64+
}
65+
```
66+
67+
To branch on *which* happened inside the flow, test `previous`: it is empty on
68+
create (there was no prior row) and populated on update. For example, an edge
69+
condition `previous == null` selects the create path, `previous != null` the
70+
update path.
4271

4372
## Usage
4473

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { RecordChangeTriggerPlugin } from './plugin.js';
44
export {
55
RecordChangeTrigger,
66
triggerTypeToHookEvent,
7+
triggerTypeToHookEvents,
78
} from './record-change-trigger.js';
89
export type {
910
FlowTrigger,

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,37 @@ function stampFlow(name: string, object: string) {
109109
};
110110
}
111111

112+
/**
113+
* A `record-after-write` flow (create OR update, #3427) that mirrors the
114+
* record's live `status` into `mirror` on every write. Its own update_record
115+
* write-back also fires afterUpdate, so this doubles as coverage that the
116+
* engine's re-entrancy guard suppresses the self-trigger loop a write flow now
117+
* exposes (afterUpdate IS bound, unlike a create-only flow).
118+
*/
119+
function mirrorWriteFlow(name: string, object: string) {
120+
return {
121+
name,
122+
label: name,
123+
type: 'record_change',
124+
nodes: [
125+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-write' } },
126+
{ id: 'mirror', type: 'update_record', label: 'Mirror', config: { objectName: object, filter: { id: '{record.id}' }, fields: { mirror: '{record.status}' } } },
127+
{ id: 'end', type: 'end', label: 'End' },
128+
],
129+
edges: [
130+
{ id: 'e1', source: 'start', target: 'mirror' },
131+
{ id: 'e2', source: 'mirror', target: 'end' },
132+
],
133+
};
134+
}
135+
112136
const objectDef = (name: string) => ({
113137
name,
114138
label: name,
115139
fields: {
116140
status: { name: 'status', label: 'S', type: 'text' },
117141
stamp: { name: 'stamp', label: 'St', type: 'text' },
142+
mirror: { name: 'mirror', label: 'M', type: 'text' },
118143
},
119144
});
120145

@@ -192,4 +217,37 @@ describe('record-change trigger — end-to-end (#1491)', () => {
192217
const row = await data.findOne('wid2', { where: { id } });
193218
expect(row?.stamp).toBe('done');
194219
}, 15000);
220+
221+
it('a single record-after-write flow fires on BOTH create and update (#3427)', async () => {
222+
const kernel = new ObjectKernel({ logLevel: 'silent' });
223+
await kernel.use(new ObjectQLPlugin());
224+
await kernel.use(new AutomationServicePlugin());
225+
await kernel.use(new RecordChangeTriggerPlugin());
226+
await kernel.bootstrap();
227+
228+
const objectql = kernel.getService('objectql') as any;
229+
const data = kernel.getService('data') as any;
230+
const automation = kernel.getService<AutomationEngine>('automation');
231+
232+
objectql.registerDriver(makeMemoryDriver(), true);
233+
objectql.registry.registerObject(objectDef('wid3'), 'test', 'test');
234+
automation.registerFlow('mirror_write', mirrorWriteFlow('mirror_write', 'wid3') as any);
235+
236+
expect((automation as any).getActiveTriggerBindings()).toContainEqual({
237+
flowName: 'mirror_write',
238+
triggerType: 'record_change',
239+
});
240+
241+
// Create — the afterInsert leg fires; the flow mirrors status → mirror.
242+
const created = await data.insert('wid3', { status: 'a' });
243+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
244+
await sleep(200);
245+
expect((await data.findOne('wid3', { where: { id } }))?.mirror).toBe('a');
246+
247+
// Update — the afterUpdate leg of the SAME flow fires; mirror re-syncs. (The
248+
// flow's own write-back does not loop: the re-entrancy guard suppresses it.)
249+
await data.update('wid3', { id, status: 'b' });
250+
await sleep(200);
251+
expect((await data.findOne('wid3', { where: { id } }))?.mirror).toBe('b');
252+
}, 15000);
195253
});

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { HookContext } from '@objectstack/spec/data';
66
import {
77
RecordChangeTrigger,
88
triggerTypeToHookEvent,
9+
triggerTypeToHookEvents,
910
type FlowTriggerBinding,
1011
type RecordChangeDataEngine,
1112
type TriggerLogger,
@@ -90,6 +91,35 @@ describe('triggerTypeToHookEvent', () => {
9091
expect(triggerTypeToHookEvent('record-after-frobnicate')).toBeNull();
9192
expect(triggerTypeToHookEvent('on_update')).toBeNull();
9293
});
94+
95+
it('returns null for the multi-event write token (use triggerTypeToHookEvents)', () => {
96+
// `write` maps to TWO events, which the singular mapper cannot express —
97+
// it returns null rather than silently dropping one binding.
98+
expect(triggerTypeToHookEvent('record-after-write')).toBeNull();
99+
expect(triggerTypeToHookEvent('record-before-write')).toBeNull();
100+
});
101+
});
102+
103+
// ─── triggerTypeToHookEvents ────────────────────────────────────────
104+
105+
describe('triggerTypeToHookEvents', () => {
106+
it('maps single-lifecycle tokens to a one-element list', () => {
107+
expect(triggerTypeToHookEvents('record-after-create')).toEqual(['afterInsert']);
108+
expect(triggerTypeToHookEvents('record-after-update')).toEqual(['afterUpdate']);
109+
expect(triggerTypeToHookEvents('record-before-delete')).toEqual(['beforeDelete']);
110+
expect(triggerTypeToHookEvents('record-after-insert')).toEqual(['afterInsert']);
111+
});
112+
113+
it('expands `write` into the create-OR-update union (#3427)', () => {
114+
expect(triggerTypeToHookEvents('record-after-write')).toEqual(['afterInsert', 'afterUpdate']);
115+
expect(triggerTypeToHookEvents('record-before-write')).toEqual(['beforeInsert', 'beforeUpdate']);
116+
});
117+
118+
it('returns an empty list for unsupported / missing tokens', () => {
119+
expect(triggerTypeToHookEvents(undefined)).toEqual([]);
120+
expect(triggerTypeToHookEvents('schedule')).toEqual([]);
121+
expect(triggerTypeToHookEvents('record-after-frobnicate')).toEqual([]);
122+
});
93123
});
94124

95125
// ─── RecordChangeTrigger ────────────────────────────────────────────
@@ -116,6 +146,52 @@ describe('RecordChangeTrigger', () => {
116146
expect(hooks).toHaveLength(0);
117147
});
118148

149+
it('binds BOTH afterInsert and afterUpdate for record-after-write (create OR update, #3427)', () => {
150+
const { engine, hooks } = fakeEngine();
151+
const trigger = new RecordChangeTrigger(engine, silentLogger());
152+
153+
trigger.start(binding({ event: 'record-after-write' }), async () => {});
154+
155+
// One start node → both lifecycle hooks, same object, same packageId
156+
// (so a single stop() tears both down).
157+
expect(hooks).toHaveLength(2);
158+
expect(hooks.map((h) => h.event).sort()).toEqual(['afterInsert', 'afterUpdate']);
159+
expect(hooks.every((h) => h.object === 'showcase_task')).toBe(true);
160+
expect(new Set(hooks.map((h) => h.packageId)).size).toBe(1);
161+
expect(hooks[0].packageId).toBe('com.objectstack.trigger.record-change:task_assigned_notify');
162+
});
163+
164+
it('a record-after-write flow fires on both the insert hook and the update hook', async () => {
165+
const { engine, hooks } = fakeEngine();
166+
const trigger = new RecordChangeTrigger(engine, silentLogger());
167+
let fired = 0;
168+
169+
trigger.start(binding({ event: 'record-after-write' }), async () => {
170+
fired += 1;
171+
});
172+
173+
const insertHook = hooks.find((h) => h.event === 'afterInsert')!;
174+
const updateHook = hooks.find((h) => h.event === 'afterUpdate')!;
175+
176+
// Insert: no previous row.
177+
await insertHook.handler(hookCtx({ event: 'afterInsert', previous: undefined }));
178+
// Update: previous row present.
179+
await updateHook.handler(hookCtx({ event: 'afterUpdate' }));
180+
181+
expect(fired).toBe(2);
182+
});
183+
184+
it('stop() tears down BOTH hooks of a record-after-write flow', () => {
185+
const { engine, hooks } = fakeEngine();
186+
const trigger = new RecordChangeTrigger(engine, silentLogger());
187+
188+
trigger.start(binding({ event: 'record-after-write' }), async () => {});
189+
expect(hooks).toHaveLength(2);
190+
191+
trigger.stop('task_assigned_notify');
192+
expect(hooks).toHaveLength(0);
193+
});
194+
119195
it('warns when the flow targets an object the engine does not know (silent-miss guard)', () => {
120196
// 2026-07-17 third-party eval: a flow whose start-node `objectName`
121197
// does not match any registered object binds a hook that never fires —

0 commit comments

Comments
 (0)