Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/record-after-write-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/trigger-record-change": minor
---

feat(trigger-record-change): `record-after-write` fires one flow on create OR update (#3427)

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 ("recompute the
SLA whenever a case is created or its priority changes") forced authors to
duplicate the whole flow — two near-identical definitions that drift.

Adds `record-after-write` and `record-before-write` as the **create-OR-update
union** trigger tokens. 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.
`delete` is deliberately excluded — a write persists field data, a delete
removes the row. To branch on which event fired inside the flow, test
`previous` (empty on create, populated on update).

- `triggerTypeToHookEvents(triggerType)` (new, plural) is the canonical mapper:
it returns the list of hook events a token binds, expanding `write` to both.
`triggerTypeToHookEvent` (singular) is kept for back-compat and now returns
`null` for the multi-event `write` tokens rather than silently dropping a
binding.
- The engine already forwards any `record-*` token through to this trigger, so
no engine, lint, or spec change is needed — the trigger owns the vocabulary.

Documented under Automation › Flows (Create-or-update flow) and the trigger's
README.
45 changes: 45 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -816,10 +816,55 @@ flows: [
],
```

### Create-or-update flow (`record-after-write`)

A `record_change` start node binds to lifecycle events through `triggerType`.
The single-event tokens map one-to-one:

| `triggerType` | Fires on |
| :--------------------- | :------------------------------ |
| `record-after-create` | insert (after) |
| `record-after-update` | update (after) |
| `record-after-delete` | delete (after) |
| `record-after-write` | **insert *or* update (after)** |
| `record-before-create` | insert (before) |
| `record-before-update` | update (before) |
| `record-before-delete` | delete (before) |
| `record-before-write` | **insert *or* update (before)** |

For a rule that must run on **both create and update** — "recompute the SLA
whenever a case is created or its priority changes" — use `record-after-write`
instead of duplicating the flow. `write` is the create-OR-update union (delete
is excluded: a write persists field data, a delete removes the row). One `start`
node binds both lifecycle events; exactly one fires per mutation, so it is not a
double run.

```typescript
{
id: 'start',
type: 'start',
label: 'Start',
config: {
objectName: 'crm_case',
triggerType: 'record-after-write', // created OR updated
},
}
```

To branch on **which** event fired, test `previous` — it is empty on create
(there was no prior row) and populated on update:

```typescript
// Edge conditions are bare CEL (ADR-0032) — no {…} braces.
{ id: 'e_created', source: 'start', target: 'on_create', condition: 'previous == null' },
{ id: 'e_updated', source: 'start', target: 'on_update', condition: 'previous != null' },
```

### Best practices

✅ **DO:**
- Keep flows simple and focused
- Prefer one `record-after-write` flow over two near-identical create/update copies
- Document the business logic
- Test recursion and retry behavior
- Use scheduled flows for batch updates
Expand Down
5 changes: 4 additions & 1 deletion content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,10 @@ export const crm = defineApp({
Create an automation that fires when a record changes. The **start node's `config`
binds the trigger**: `objectName` plus one lifecycle event in `triggerType`
(`record-after-create`, `record-after-update`, `record-after-delete`, or the
`record-before-*` forms) — without it the flow never fires.
`record-before-*` forms) — without it the flow never fires. To fire on **create
or update in one flow**, use `record-after-write` (the create-OR-update union)
instead of duplicating the definition — see
[Flows › Create-or-update flow](/docs/automation/flows#create-or-update-flow-record-after-write).

```typescript
import { defineFlow } from '@objectstack/spec';
Expand Down
45 changes: 37 additions & 8 deletions packages/triggers/trigger-record-change/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,43 @@ node gate) before running the flow, with `record` (the new row) and `previous`

### Trigger event → hook mapping

| `triggerType` | ObjectQL hook |
| ------------------------ | -------------- |
| `record-after-create` | `afterInsert` |
| `record-after-update` | `afterUpdate` |
| `record-after-delete` | `afterDelete` |
| `record-before-create` | `beforeInsert` |
| `record-before-update` | `beforeUpdate` |
| `record-before-delete` | `beforeDelete` |
| `triggerType` | ObjectQL hook(s) |
| ------------------------ | ----------------------------- |
| `record-after-create` | `afterInsert` |
| `record-after-update` | `afterUpdate` |
| `record-after-delete` | `afterDelete` |
| `record-after-write` | `afterInsert` + `afterUpdate` |
| `record-before-create` | `beforeInsert` |
| `record-before-update` | `beforeUpdate` |
| `record-before-delete` | `beforeDelete` |
| `record-before-write` | `beforeInsert` + `beforeUpdate` |

`record-after-create` / `record-after-insert` are synonyms (both → `afterInsert`).

### Create **or** update in one flow: `record-*-write`

`record-after-write` (and its before-phase form) is the **create-OR-update
union** — one `start` node that fires on both insert and update, so a
"recompute whenever a record is created or changed" rule needs **one** flow, not
two near-identical copies. It binds both lifecycle hooks under the same flow;
exactly one fires per mutation (a write is an insert *xor* an update), so it is
not a double-dispatch. `delete` is deliberately excluded — a write persists
field data, a delete removes the row.

```ts
{
type: 'start',
config: {
objectName: 'crm_case',
triggerType: 'record-after-write', // created OR updated
},
}
```

To branch on *which* happened inside the flow, test `previous`: it is empty on
create (there was no prior row) and populated on update. For example, an edge
condition `previous == null` selects the create path, `previous != null` the
update path.

## Usage

Expand Down
1 change: 1 addition & 0 deletions packages/triggers/trigger-record-change/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { RecordChangeTriggerPlugin } from './plugin.js';
export {
RecordChangeTrigger,
triggerTypeToHookEvent,
triggerTypeToHookEvents,
} from './record-change-trigger.js';
export type {
FlowTrigger,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,37 @@ function stampFlow(name: string, object: string) {
};
}

/**
* A `record-after-write` flow (create OR update, #3427) that mirrors the
* record's live `status` into `mirror` on every write. Its own update_record
* write-back also fires afterUpdate, so this doubles as coverage that the
* engine's re-entrancy guard suppresses the self-trigger loop a write flow now
* exposes (afterUpdate IS bound, unlike a create-only flow).
*/
function mirrorWriteFlow(name: string, object: string) {
return {
name,
label: name,
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-write' } },
{ id: 'mirror', type: 'update_record', label: 'Mirror', config: { objectName: object, filter: { id: '{record.id}' }, fields: { mirror: '{record.status}' } } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'mirror' },
{ id: 'e2', source: 'mirror', target: 'end' },
],
};
}

const objectDef = (name: string) => ({
name,
label: name,
fields: {
status: { name: 'status', label: 'S', type: 'text' },
stamp: { name: 'stamp', label: 'St', type: 'text' },
mirror: { name: 'mirror', label: 'M', type: 'text' },
},
});

Expand Down Expand Up @@ -192,4 +217,37 @@ describe('record-change trigger — end-to-end (#1491)', () => {
const row = await data.findOne('wid2', { where: { id } });
expect(row?.stamp).toBe('done');
}, 15000);

it('a single record-after-write flow fires on BOTH create and update (#3427)', async () => {
const kernel = new ObjectKernel({ logLevel: 'silent' });
await kernel.use(new ObjectQLPlugin());
await kernel.use(new AutomationServicePlugin());
await kernel.use(new RecordChangeTriggerPlugin());
await kernel.bootstrap();

const objectql = kernel.getService('objectql') as any;
const data = kernel.getService('data') as any;
const automation = kernel.getService<AutomationEngine>('automation');

objectql.registerDriver(makeMemoryDriver(), true);
objectql.registry.registerObject(objectDef('wid3'), 'test', 'test');
automation.registerFlow('mirror_write', mirrorWriteFlow('mirror_write', 'wid3') as any);

expect((automation as any).getActiveTriggerBindings()).toContainEqual({
flowName: 'mirror_write',
triggerType: 'record_change',
});

// Create — the afterInsert leg fires; the flow mirrors status → mirror.
const created = await data.insert('wid3', { status: 'a' });
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
await sleep(200);
expect((await data.findOne('wid3', { where: { id } }))?.mirror).toBe('a');

// Update — the afterUpdate leg of the SAME flow fires; mirror re-syncs. (The
// flow's own write-back does not loop: the re-entrancy guard suppresses it.)
await data.update('wid3', { id, status: 'b' });
await sleep(200);
expect((await data.findOne('wid3', { where: { id } }))?.mirror).toBe('b');
}, 15000);
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { HookContext } from '@objectstack/spec/data';
import {
RecordChangeTrigger,
triggerTypeToHookEvent,
triggerTypeToHookEvents,
type FlowTriggerBinding,
type RecordChangeDataEngine,
type TriggerLogger,
Expand Down Expand Up @@ -90,6 +91,35 @@ describe('triggerTypeToHookEvent', () => {
expect(triggerTypeToHookEvent('record-after-frobnicate')).toBeNull();
expect(triggerTypeToHookEvent('on_update')).toBeNull();
});

it('returns null for the multi-event write token (use triggerTypeToHookEvents)', () => {
// `write` maps to TWO events, which the singular mapper cannot express —
// it returns null rather than silently dropping one binding.
expect(triggerTypeToHookEvent('record-after-write')).toBeNull();
expect(triggerTypeToHookEvent('record-before-write')).toBeNull();
});
});

// ─── triggerTypeToHookEvents ────────────────────────────────────────

describe('triggerTypeToHookEvents', () => {
it('maps single-lifecycle tokens to a one-element list', () => {
expect(triggerTypeToHookEvents('record-after-create')).toEqual(['afterInsert']);
expect(triggerTypeToHookEvents('record-after-update')).toEqual(['afterUpdate']);
expect(triggerTypeToHookEvents('record-before-delete')).toEqual(['beforeDelete']);
expect(triggerTypeToHookEvents('record-after-insert')).toEqual(['afterInsert']);
});

it('expands `write` into the create-OR-update union (#3427)', () => {
expect(triggerTypeToHookEvents('record-after-write')).toEqual(['afterInsert', 'afterUpdate']);
expect(triggerTypeToHookEvents('record-before-write')).toEqual(['beforeInsert', 'beforeUpdate']);
});

it('returns an empty list for unsupported / missing tokens', () => {
expect(triggerTypeToHookEvents(undefined)).toEqual([]);
expect(triggerTypeToHookEvents('schedule')).toEqual([]);
expect(triggerTypeToHookEvents('record-after-frobnicate')).toEqual([]);
});
});

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

it('binds BOTH afterInsert and afterUpdate for record-after-write (create OR update, #3427)', () => {
const { engine, hooks } = fakeEngine();
const trigger = new RecordChangeTrigger(engine, silentLogger());

trigger.start(binding({ event: 'record-after-write' }), async () => {});

// One start node → both lifecycle hooks, same object, same packageId
// (so a single stop() tears both down).
expect(hooks).toHaveLength(2);
expect(hooks.map((h) => h.event).sort()).toEqual(['afterInsert', 'afterUpdate']);
expect(hooks.every((h) => h.object === 'showcase_task')).toBe(true);
expect(new Set(hooks.map((h) => h.packageId)).size).toBe(1);
expect(hooks[0].packageId).toBe('com.objectstack.trigger.record-change:task_assigned_notify');
});

it('a record-after-write flow fires on both the insert hook and the update hook', async () => {
const { engine, hooks } = fakeEngine();
const trigger = new RecordChangeTrigger(engine, silentLogger());
let fired = 0;

trigger.start(binding({ event: 'record-after-write' }), async () => {
fired += 1;
});

const insertHook = hooks.find((h) => h.event === 'afterInsert')!;
const updateHook = hooks.find((h) => h.event === 'afterUpdate')!;

// Insert: no previous row.
await insertHook.handler(hookCtx({ event: 'afterInsert', previous: undefined }));
// Update: previous row present.
await updateHook.handler(hookCtx({ event: 'afterUpdate' }));

expect(fired).toBe(2);
});

it('stop() tears down BOTH hooks of a record-after-write flow', () => {
const { engine, hooks } = fakeEngine();
const trigger = new RecordChangeTrigger(engine, silentLogger());

trigger.start(binding({ event: 'record-after-write' }), async () => {});
expect(hooks).toHaveLength(2);

trigger.stop('task_assigned_notify');
expect(hooks).toHaveLength(0);
});

it('warns when the flow targets an object the engine does not know (silent-miss guard)', () => {
// 2026-07-17 third-party eval: a flow whose start-node `objectName`
// does not match any registered object binds a hook that never fires —
Expand Down
Loading