Skip to content

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

Merged
os-zhuang merged 1 commit into
mainfrom
claude/record-change-lifecycle-binding-kobc1m
Jul 24, 2026
Merged

feat(trigger-record-change): record-after-write fires one flow on create OR update (#3427)#3446
os-zhuang merged 1 commit into
mainfrom
claude/record-change-lifecycle-binding-kobc1m

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Summary

Closes #3427.

A record_change flow's start node bound to exactly one lifecycle event via triggerType (record-after-create, record-after-update, …). A rule meant to run on both create and update — "recompute the SLA whenever a case is created or its priority changes" — forced authors to duplicate the whole flow into two near-identical definitions that drift, or drop out of the visual-flow layer into an object lifecycle hook.

This adds record-after-write (and its record-before-write before-phase form): the create-OR-update union. One start node, both lifecycle events — mirroring Salesforce's "created or updated" record-trigger option and Rails' after_save.

{ id: 'start', type: 'start', config: { objectName: 'crm_case', triggerType: 'record-after-write' } }

write is exactly insert + update — the two mutations that persist field data. delete is deliberately excluded (a write persists a row, a delete removes it). One start node binds both 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 inside the flow, test previous — empty on create, populated on update. e.g. edge condition previous == null selects the create path.

How it works

The engine's resolveTriggerBinding already forwards any record-* token straight through to this trigger, so no engine, lint, or spec change is needed — the trigger owns the record-event vocabulary. The change is localized to @objectstack/trigger-record-change:

  • triggerTypeToHookEvents(triggerType) (new, plural) is now the canonical mapper: it returns the list of ObjectQL hook events a token binds, expanding write['afterInsert', 'afterUpdate'].
  • triggerTypeToHookEvent (singular) is kept for back-compat and now returns null for the multi-event write tokens rather than silently dropping a binding.
  • RecordChangeTrigger.start registers one hook per mapped event, all under the same per-flow packageId, so a single stop() (unregisterHooksByPackage) tears both down together.
  • A record-after-write flow whose action updates its own trigger record does not loop: the engine's existing flowName::recordId re-entrancy guard suppresses the self-fire (now covered by an end-to-end test).

Tests

  • Unit (record-change-trigger.test.ts): triggerTypeToHookEvents maps write → both events for both phases; the singular mapper returns null for write; start() binds both afterInsert + afterUpdate (same object, same packageId), fires on each, and stop() tears both down.
  • End-to-end (record-change-integration.test.ts): a single record-after-write flow booted on a real kernel fires on both an insert and a subsequent update, and its own write-back does not loop.

All 33 tests in the package pass; the package builds clean (DTS typecheck).

Docs

  • Automation › Flows: new Create-or-update flow section with the full triggerType → event table and the previous-based create/update discrimination pattern.
  • Getting-started common patterns + the trigger README.md updated.

Changeset

minor bump for @objectstack/trigger-record-change.


Companion UI PR (objectui): the Studio flow designer's start-node trigger picker gains a "Record created or updated" option, with record + previous correctly in scope for it.


Generated by Claude Code

…ate 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 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Building Building Preview, Comment Jul 24, 2026 2:50pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 1 changed package(s). ✅

@os-zhuang
os-zhuang marked this pull request as ready for review July 24, 2026 15:05
@os-zhuang
os-zhuang merged commit 6f55c63 into main Jul 24, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/record-change-lifecycle-binding-kobc1m branch July 24, 2026 15:06
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Jul 25, 2026
…ation lint, showcase flow, create-leg `previous` fix (objectstack-ai#3427) (objectstack-ai#3467)

* feat(lint,showcase): flag never-firing record trigger tokens; add record-after-write showcase flow (objectstack-ai#3427)

Follow-ups to the record-after-write feature (objectstack-ai#3446).

Lint (item 3): new `flow-trigger-unknown-event` rule in validateFlowTriggerReadiness
flags a start node whose `triggerType` is record-lifecycle-shaped
(`record-before|after-<op>`) but names an op the record-change trigger cannot map
— a typo like `record-after-updated`. The engine still routes any `record-` token
to the record trigger, which then binds to NO hook and never fires (only a runtime
warn), so this surfaces the never-fire defect at authoring/`os validate` time.
Warning severity, consistent with the file's existing rules. Bare `record-<noun>`
shapes (e.g. `record-change`) are out of scope for this rule.

Showcase (item 2): add `UrgentTaskAlertFlow`, a single `record-after-write` flow
that fires when a task is created Urgent OR escalated to Urgent — using the
`previous == null` create/update discrimination the write trigger enables. Plus a
durable integration test booting a real kernel that verifies the create leg
(`previous == null` → fires on afterInsert), the non-urgent create (no fire), and
the escalation leg (fires on afterUpdate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa

* fix(service-automation): bind `previous` as null on the create leg; fix showcase notify recipient (objectstack-ai#3427)

Dogfooding the record-after-write showcase flow in a live app surfaced two bugs:

1. The engine bound `previous` into the flow condition scope only when truthy, so
   on a record insert `previous` was an unknown CEL variable — the documented
   `previous == null` create-discrimination threw "Unknown variable: previous" and
   failed the whole start condition, dropping the run. `previous` is now always
   bound (null when there is no prior row), making the create/update discrimination
   the record-after-write docs + Studio designer advertise actually work. Verified
   end-to-end (integration test + a live showcase boot: the flow fires on
   create-urgent and on escalation, and correctly skips a non-urgent create).

2. The showcase `UrgentTaskAlertFlow` notify node had no recipient, so every run
   failed "at least one recipient is required". Now notifies the assignee, falling
   back to the triggering user (`{$User.Id}`) so an unassigned urgent task still
   pings someone. Live-verified: a `task.urgent` notification is delivered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa

* chore: add changeset for the flow-trigger-unknown-event lint rule

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018939yJ413zG3irLzcTtqaa

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record_change flow start node binds to a single lifecycle event — no create-OR-update in one flow

2 participants