Files
nocobase[bot] a1c353b825 chore: merge release 2026-10-12.3 into v3-develop [skip ci]
current app-template-default version: 1.0.0-beta.64
2026-10-12 04:56:14 +00:00
..

@nocobase/app-plugin-lifecycle-example

Shows record lifecycles built on @nocobase/lifecycle: a business record keeps its state in one of its own fields, every change is a transition declared in source, and the side effects a transition owes run after it commits, with retries. There is no separate process instance — where a ticket or an expense stands is its status.

Two lifecycles run under Workflow in the application menu, each behind a page that reads like the product rather than like the lifecycle:

Page Lifecycle What it shows
Help desk server/lifecycles/ticket.ts A customer files a ticket; agents take it from a queue and reply; a ticket left waiting on the customer closes itself, a customer reply brings it back, and a closed ticket can be reopened for a week. The conversation is the transition log
Expense reports server/lifecycles/expense.ts An employee claims itemized expenses; the amount decides whether it is approved automatically or needs the manager and finance; approvers approve, send back or reject with a reason; an idle manager is passed over; payment runs as an effect

The help desk and expense reports each have a Signed in as switch over the example's people — agents and customers, or employees, managers, an executive and the finance director — so one person can play every role; a real application takes the actor from the signed-in user and authorizes the action. The waits are minutes rather than days so you can watch a trigger fire, and a demo option on each new record makes its first email deliveries or payment attempts fail on purpose, to show retries. Under each record, Under the hood shows its states, parameters, transition log and effect runs.

Durable flows

The E-invoicing page demonstrates one invoice per completed sandbox order: submit with up to three attempts, wait for a signed platform callback, query after 30 seconds without a callback, and retry a failed invoice under the same provider request key. Orders #1024 and #1026 are normal; #1025 fails its first three submissions. Hold the callback on #1026 to observe the timeout query. Issuing notifies the customer through an idempotent sandbox effect and enables a PDF download marked SANDBOX INVOICE - NOT VALID FOR TAX. These orders are a fixed demonstration catalog, independent of the Order payment page; no real invoicing or tax platform is called. New and existing installations can load the samples with pnpm nocobase db sample, recorded separately as lifecycle-example/invoices.

Six more pages under the same menu show processes that stop to wait for systems outside the application and are moved on by them. While one waits it is only a state on its record — no timer, no open request, no process holding it — so a restart loses nothing, and whatever arrives late, early, twice or out of order ends in exactly one well-defined place.

Page Lifecycle What it shows
Order payment server/lifecycles/order.ts Paying opens the provider's hosted checkout as an effect; only the provider's webhook marks the order paid. A redelivered webhook replays, one that overtakes the checkout's own effect is accepted, an unpaid order expires on a trigger, and a payment arriving after cancellation is refunded
Data exports server/lifecycles/export.ts A vendor with no webhook is polled: a trigger fires the self-transition poll, re-entering processing runs checkJob, and the overall deadline is written on the record and read by the poll's route
Flash sale server/lifecycles/purchase.ts Reserve stock, then charge; a declined card is undone by a compensating releasing step of its own, and a compensation that keeps failing stops in a state a person retries
Shipping server/lifecycles/fulfilment.ts Waits for the payment and the warehouse in either order, each signal routing on the other; carrier events are applied by when they happened, an older one refused as stale
Subscriptions server/lifecycles/subscription.ts Charged every period for as long as it lives; the plugin's sweep renews at each subscription's own period end, and a declined card is retried by the dunning trigger under a new key until it cancels

These pages have no persona switch, and their lifecycles no guard about who asks: they are about waiting for the outside, not about who may act. A person's step is the signed-in user's — the record routes take no actAs for these flows — and the customer a record is for is a field of it, chosen in its form.

The outside is a sandbox in the plugin's own tables, server/sandbox/: a payment provider with hosted checkouts, charges and refunds, a warehouse with stock and reservations, a slow vendor and a carrier, each call keyed by an idempotency key so a repeated call answers what the first made. The sandbox sends its webhooks signed to POST /api/lifecycleExample/webhooks/{source} (server/webhooks/receiver.ts), which takes no session — a provider has none — and checks the x-sandbox-signature HMAC instead. The receiver fires the transitions an event maps to under the request id <source>:<event id>, tries the next one when the record's state does not allow the first, acknowledges what applied, replayed or can never apply, and answers an error for a conflict so the sender delivers again; the sandbox does, on a later sweep. Each page's dashed card plays those systems: pay or decline on the checkout, send the warehouse's or the carrier's event, hold a webhook and deliver it later, out of order, or twice.

Every record shows an interactive React Flow diagram with ELK routing, copied from the UI Library’s flow-diagram and lifecycle-diagram items. The current state has a stronger fill, visited states and actual traversed transitions share one accent color, available transitions use the action accent color, and selecting a state or transition opens its details. Each explicitly final state has its own connected end marker. The folded Mermaid source remains available for documentation and export.

Try these:

  1. A payment after cancellation. Place an order and pay. On the payment provider card tick Hold the webhook, click Pay, then Cancel order, then Deliver the held webhook: it arrives after the order was cancelled, fires paidAfterCancel, and the money is refunded. Deliver again answers Replayed.
  2. Polling. Request an export the vendor takes 30 seconds over, and watch what the vendor knows run ahead of what the export knows until the next poll. Choose gets stuck at 60% to watch it time out and stop the vendor's job.
  3. Compensation. Buy with The card is declined ticked, and watch the units leave the shelf and come back. Make the release fail three times to see it stop and wait, then retry it.
  4. Out of order. Open a shipment, send Warehouse picked and Payment captured in either order, hand it to the carrier, then Hold three scans and deliver the later one first: the earlier scan is ignored as stale.
  5. Dunning. Subscribe with a card that declines one charge: it is past due, retried a minute later, and recovers; Update card recovers it at once.

tests/fixtures/second-layer/ shows what goes on while a record waits in one state, written by hand as test fixtures without a page of its own: a visa waits in supplementing while the applicant and the officer exchange material in rounds, and an order waits in replanning while an assistant asks the planner to choose. Each keeps its rounds in rows of its own, set up and ended by the state's onEnterState and onLeaveState hooks, and moves the record only when it concludes, by firing a manual: false transition in runtime.transaction() — so the record's version and clock stay where they were until then. They run only on the memory store and are not part of the published plugin; a real plugin keeps such rows in tables of its own, created by a migration and written through the transaction's Repository. A reusable second layer, such as an approval layer, could provide the whole state in the same way.

Try it

Run the examples application with pnpm --filter @nocobase/app-template-examples dev, sign in, and open Workflow in the menu. Each page opens with an About this example card saying what the page shows and how to try it, step by step; fold it away once read, and the page remembers that in the browser.

Installed with sample data (APP_SAMPLE_DATA=true), the pages open on records to play with: tickets that are new, with their agent, resolved and closed for silence; expense reports as a draft, sent back, rejected, waiting for the finance director and paid; and orders, exports, purchases and a shipment in their first state. On a database installed without them, pnpm nocobase db sample builds them. They are built in server/samples.ts, registered on sampleDataToken rather than written by a seed, because a lifecycle record is only right together with its log: each is created through the service and moved on with runtime.fire(), so its transitions, versions and effect runs are the real ones. None is left where a trigger would move it before anyone looks, and there is no sample subscription, because creating one charges the card.

  1. See why a button is greyed out. As an employee, file a 6,000 expense report and submit it. Switch to another employee: under Under the hood, "What the current identity can do" lists ✗ approve with the reason.
  2. See an escalation. Leave the report alone for three minutes, or click Run triggers now. The approver becomes the manager's manager, the log gains an escalate row fired by the system, and the version goes up by one.
  3. See retries. Create a report with "payments that fail on purpose" set to 5 and approve it. The payment run fails three times and becomes failed. Click Retry: it gets three more attempts; the fourth and fifth fail, the sixth succeeds, the report becomes paid and the payment reference is written onto it.
  4. See the concurrency guard. Open the same waiting report in two windows. Approve it in one, then send it back in the other: the second click is refused because the version it saw is stale.
  5. See the diagram. The panel shows the interactive lifecycle diagram, its parameters, its log and its effect runs; select a state or transition for details, or expand the Mermaid source for export.

How it is wired

Concern Where
States and transitions server/lifecycles/*.ts, the only definition of each flow
Effects server/lifecycles/*.effects.ts, plain functions
Records and the log database/migrations/, through the Repository store
Effects, triggers, recovery createLifecycleJobs() from @nocobase/lifecycle/jobs: a JobExecutor job per effect run, a ScheduleExecutor sweep every 10 seconds that reclaims expired attempts, fires triggers and prunes old runs, and recover() on start
Record routes server/routes/lifecycle.ts: the description, a record's view, firing and an operator's retry, continue and cancel under /api/lifecycleExample/{tickets,expenses}, at the paths the React hook calls, declared with describeRoute() and apiValidator(); refusals become the standard error body through lifecycleErrorFields()
Record pages createLifecycleHook() from @nocobase/lifecycle/react, configured once in client/lib/use-example-record.ts with the API client as transport; the routes' path is shared with the server in shared/routes.ts
Page refresh No polling: the provider publishes { lifecycle, recordId } on the realtime topic lifecycle-example:changes (shared/routes.ts) after every committed transition and every stored or delivered sandbox webhook, and a page reloads the list and the record it shows when told (client/lib/use-lifecycle-changes.ts), and once more when the connection reopens. The runtime says nothing when an effect finishes without a transition, so a page looks again only while one of the record's effect runs is queued or running, when it is due (client/lib/use-follow-up.ts)

Patterns worth copying

Problem Pattern Where
One button, several destinations to as an array and a route shared by submit and resubmit; the limits are parameters server/lifecycles/expense.ts
Telling the person why a button is greyed out Guards return { code, message }; the client translates the code through its locales and falls back to the message server/lifecycles/*.ts, client/lib/api.ts
Checking the record before it may move on readyToSubmit validates the lines inside set and throws INVALID_INPUT, computing the approver at the same time server/lifecycles/expense.ts
A reason that must be given validate: reasonRequired returning [{ field: 'reason', message }] server/lifecycles/expense.ts, ticket.ts
Withdraw from anywhere under way from: { except: ['draft', 'approved'] } server/lifecycles/expense.ts
An idle approver is passed over The self-transition escalate, guarded to the system, fired by the trigger escalateStale server/lifecycles/expense.ts
An external call whose result moves the record on requestPayment in onEnter.approved, with retries, a timeout and onSuccess: 'paid'; paid accepts paymentRef server/lifecycles/expense.effects.ts, expense.ts
A conversation that is the history Every reply is the input of the transition it caused, read back from the transition log server/lifecycles/ticket.ts, client/pages/tickets.tsx
Reopening within a window A guard that compares statusChangedAt with a parameter and refuses with kind: 'precondition', answered 400 server/lifecycles/ticket.ts

Testing

tests/lifecycles.test.ts tests both lifecycles with @nocobase/lifecycle/testing: a memory store, a fake clock and in-process effects, so waiting, escalating and retrying are plain function calls. tests/provider.test.ts runs the real Provider on the test database (SQLite by default) with the memory jobs service, tests/routes.test.ts covers the HTTP boundary, tests/concurrent-edits.test.ts covers an edit racing a submit or another edit on the test database, and tests/error-message.test.ts covers how a page words a refusal.

tests/durable-flows.test.ts runs the six durable flows the same way, with the real sandbox on memory objects and the kit's clock, and the webhook receiver on the kit's runtime: replays, an early and a late webhook, polling to a deadline, compensation, signals in either order, stale carrier events and dunning. tests/provider.test.ts also runs them on the test database, the webhook through its HTTP route with its signature.

tests/second-layer.test.ts runs the two hand-written second layers in tests/fixtures/second-layer/ on memory storage and a fake clock: rounds that leave the record alone, the conclusion that moves it, and a late answer for a stay that has already ended being refused.

pnpm --filter @nocobase/app-plugin-lifecycle-example lint
pnpm --filter @nocobase/app-plugin-lifecycle-example typecheck
pnpm --filter @nocobase/app-plugin-lifecycle-example test
pnpm --filter @nocobase/app-plugin-lifecycle-example build