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 anyrecord-* 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 bothafterInsert + 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

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specBuildingBuildingPreview, CommentJul 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 mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

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

@os-zhuang@claude