diff --git a/.changeset/record-after-write-trigger.md b/.changeset/record-after-write-trigger.md new file mode 100644 index 000000000..fa5f4ca18 --- /dev/null +++ b/.changeset/record-after-write-trigger.md @@ -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. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 5b670d730..d64ec004b 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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 diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index cb0f0791d..e01da4d74 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -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'; diff --git a/packages/triggers/trigger-record-change/README.md b/packages/triggers/trigger-record-change/README.md index afc4248ae..b392312b2 100644 --- a/packages/triggers/trigger-record-change/README.md +++ b/packages/triggers/trigger-record-change/README.md @@ -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 diff --git a/packages/triggers/trigger-record-change/src/index.ts b/packages/triggers/trigger-record-change/src/index.ts index 831d477a9..875099a8b 100644 --- a/packages/triggers/trigger-record-change/src/index.ts +++ b/packages/triggers/trigger-record-change/src/index.ts @@ -4,6 +4,7 @@ export { RecordChangeTriggerPlugin } from './plugin.js'; export { RecordChangeTrigger, triggerTypeToHookEvent, + triggerTypeToHookEvents, } from './record-change-trigger.js'; export type { FlowTrigger, diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index f0af24038..2f23a2668 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -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' }, }, }); @@ -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('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); }); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts index 54b55fe11..03f72ca49 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.test.ts @@ -6,6 +6,7 @@ import type { HookContext } from '@objectstack/spec/data'; import { RecordChangeTrigger, triggerTypeToHookEvent, + triggerTypeToHookEvents, type FlowTriggerBinding, type RecordChangeDataEngine, type TriggerLogger, @@ -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 ──────────────────────────────────────────── @@ -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 — diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index c0a1e26da..6e2bef96e 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -69,18 +69,42 @@ export interface TriggerLogger { const TRIGGER_PREFIX = 'com.objectstack.trigger.record-change'; /** - * Map a flow start node's `triggerType` (e.g. `record-after-update`) to an - * ObjectQL `HookEvent` (e.g. `afterUpdate`). Returns `null` for anything that - * isn't a `record-(before|after)-(create|insert|update|delete)` token. + * Map a flow start node's `triggerType` (e.g. `record-after-update`) to the + * ObjectQL `HookEvent`(s) it binds. Returns a list because one token can bind + * more than one lifecycle event: + * + * - Single ops (`create`/`insert`/`update`/`delete`) → one event, e.g. + * `record-after-update` → `['afterUpdate']`. + * - `write` is the **create-OR-update union** (#3427) — the two mutations that + * persist field data (delete is deliberately excluded) — so + * `record-after-write` → `['afterInsert', 'afterUpdate']`. This is what lets a + * single flow react to "record created or updated" without duplicating the + * whole definition, mirroring Salesforce's "created or updated" record-trigger + * option and Rails' `after_save`. + * + * Returns `[]` for anything that isn't a + * `record-(before|after)-(create|insert|update|delete|write)` token. */ -export function triggerTypeToHookEvent(triggerType: string | undefined): string | null { - if (!triggerType) return null; - const m = /^record-(before|after)-(create|insert|update|delete)$/.exec(triggerType.trim()); - if (!m) return null; +export function triggerTypeToHookEvents(triggerType: string | undefined): string[] { + if (!triggerType) return []; + const m = /^record-(before|after)-(create|insert|update|delete|write)$/.exec(triggerType.trim()); + if (!m) return []; const phase = m[1]; // 'before' | 'after' - const op = m[2]; // create|insert|update|delete + const op = m[2]; // create|insert|update|delete|write + if (op === 'write') return [`${phase}Insert`, `${phase}Update`]; // create OR update const verb = op === 'create' || op === 'insert' ? 'Insert' : op.charAt(0).toUpperCase() + op.slice(1); - return `${phase}${verb}`; // e.g. 'afterUpdate', 'beforeDelete' + return [`${phase}${verb}`]; // e.g. 'afterUpdate', 'beforeDelete' +} + +/** + * Back-compat single-event mapper for a `triggerType` token. Returns the ONE + * hook event a single-lifecycle token binds, or `null` for an unknown token OR a + * multi-event token (`record-*-write` maps to two events — use + * {@link triggerTypeToHookEvents}, which the trigger itself calls). + */ +export function triggerTypeToHookEvent(triggerType: string | undefined): string | null { + const events = triggerTypeToHookEvents(triggerType); + return events.length === 1 ? events[0] : null; } /** @@ -111,8 +135,12 @@ export class RecordChangeTrigger implements FlowTrigger { } start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { - const hookEvent = triggerTypeToHookEvent(binding.event); - if (!hookEvent) { + // One token may bind more than one lifecycle event (`record-after-write` + // → afterInsert + afterUpdate). Exactly one of a `write` flow's two hooks + // fires per mutation (a write is an insert XOR an update), so this is not + // a double-dispatch. + const hookEvents = triggerTypeToHookEvents(binding.event); + if (hookEvents.length === 0) { this.logger.warn( `[record-change] flow '${binding.flowName}' has unsupported trigger event '${binding.event ?? '(none)'}' — not bound`, ); @@ -170,13 +198,17 @@ export class RecordChangeTrigger implements FlowTrigger { } }; - this.engine.registerHook(hookEvent, handler, { - object: binding.object, - packageId, - }); + // Bind every mapped hook event under the SAME packageId so stop()'s single + // unregisterHooksByPackage tears all of them down together. + for (const hookEvent of hookEvents) { + this.engine.registerHook(hookEvent, handler, { + object: binding.object, + packageId, + }); + } this.bound.set(binding.flowName, packageId); this.logger.info( - `[record-change] bound flow '${binding.flowName}' → ${hookEvent}${binding.object ? ` on '${binding.object}'` : ''}`, + `[record-change] bound flow '${binding.flowName}' → ${hookEvents.join(' + ')}${binding.object ? ` on '${binding.object}'` : ''}`, ); }