flow-engine
A typed flow engine with zero third-party dependencies. You register steps — plain typed functions — in code, and describe flows as JSON that can live in your repo or arrive from a backend. The engine runs them with branching, loops, parallel branches, sub-flows and structured error handling.
Every run is controllable from the outside — cancel, pause, resume,
suspend — and every run is durable: after each node
the engine commits a JSON checkpoint you can persist, and
restore() continues an unfinished run after a page
refresh or on another server instance.
Getting started
Install#
npm i @dmytromykhailiuk/flow-engine
The only runtime dependency is
@dmytromykhailiuk/execution-blocker (itself
zero-dependency) — it backs the FIFO run queues. The engine runs in
browsers and on Node ≥18; nothing in it touches
the DOM.
Quick start#
Register steps and predicates in code, describe the flow as data, run it. Steps are the only place where real code — API calls, side effects — lives; everything else is serialisable config.
import { createFlowRunner } from "@dmytromykhailiuk/flow-engine";
const runner = createFlowRunner((b) => b
.registerStep("loadUser", async (input: { id: string }, _cfg: void, ctx) => ({
user: await api.getUser(input.id, { signal: ctx.signal }),
}))
.registerStep("greet", (input: { name: string }, cfg: { greeting: string }) => ({
message: `${cfg.greeting}, ${input.name}!`,
}))
.registerCondition("isPremiumUser", (ctx) =>
(ctx.steps.loadUser as any)?.user?.plan === "premium",
)
.registerFlow("welcome", {
nodes: [
{ step: "loadUser", input: { id: "input.userId" } },
{
if: [
{
when: "$isPremiumUser",
flow: [{
step: "greet",
input: { name: "steps.loadUser.user.name" },
config: { greeting: "Welcome back" },
}],
},
],
else: [{
step: "greet",
input: { name: "steps.loadUser.user.name" },
config: { greeting: "Hello" },
}],
},
],
output: "steps.greet.message",
}),
);
const handle = runner.run("welcome", { userId: "42" });
const result = await handle.getResult(); // never rejects
if (result.status === "completed") {
console.log(result.output); // "Welcome back, Ada!"
}
// external control, any time:
handle.pause();
handle.resume();
handle.cancel("user navigated away");
A flow config is plain JSON. Inline it in code (as above and
you get step-id autocomplete and typed config),
register it by name, load it from a backend with
applyConfig(), or pass it
straight to run(). Whatever the source, it is
validated before anything executes.
Core concepts#
A named sync/async function (input, config, ctx) => output,
registered in the builder. The only place real code lives — API
calls, side effects, business logic.
A JSON config: an array of nodes
(step, if, loop,
parallel, subflow, assign,
finish, break, continue,
try) plus optional output,
tag, guards and queue.
The data of one run: { input, steps, vars }.
input is what the flow was started with;
steps.<id> holds each completed step's output;
vars is written by assign
nodes (and seeded via FlowRunOptions.vars). Step
outputs are addressable — nothing overwrites anything unless you
ask it to.
A string like "steps.loadUser.user.age >= 18",
parsed by the engine's own safe
interpreter — never compiled to JavaScript.
A named JS condition registered with
registerCondition; referenced from configs as
"$name" — for logic too complex for an expression.
What createFlowRunner() returns:
run, runHook, restore,
validateFlow, applyConfig, background
flows, subscribe.
Control over one run: getResult() (a promise that
never rejects), live getStatus(),
getSnapshot(), cancel,
pause / resume.
API
Builder API#
createFlowRunner(configure, options?) hands your callback
a builder; every register* call returns the builder with
the new entry accumulated in its generic parameters.
That is where the typing comes from: flow configs written in
TypeScript autocomplete step ids and type-check each step's
config against the registry.
const runner = createFlowRunner(
(b) => b
.registerStep(/* … */)
.registerCondition(/* … */)
.registerFlow(/* … */)
.registerGuard(/* … */)
.registerHook(/* … */)
.registerSource(/* … */)
.registerBackgroundFlows(/* … */),
{
onEvent: (e) => console.log(e.type), // every run of this runner
defaultStepTimeout: 30_000, // ms, overridable per node
queueAdapter: myBlocker, // see Queues
heartbeatMs: 10_000, // see Backend example
},
);
After createFlowRunner() returns, the code registries
(steps, predicates, sources) are frozen. The declarative layer —
named flows, hooks, guards, background groups — can still be swapped
atomically later with applyConfig().
Everything registered through the builder is validated eagerly:
a broken flow config throws FlowValidationError at
startup, not at the first run.
registerStep(id, handler)
The handler signature is
(input, config, ctx) => output | Promise<output>.
The input type is what flow configs must map into the
step; config is the static per-node configuration; the
output lands in context.steps[id].
b.registerStep(
"chargeCard",
async (input: { amount: number }, cfg: { currency: string }, ctx) => {
// ctx.signal aborts on cancel() and on the step's timeout
const receipt = await payments.charge(input.amount, cfg.currency, {
signal: ctx.signal,
idempotencyKey: ctx.executionKey, // stable across retries AND restore
});
return { receiptId: receipt.id };
},
)
The third argument, StepContext:
Fires on cancel() and on the step's
timeout. Pass it into fetch and
anything else abortable.
A readonly snapshot of { input, steps, vars } at
step start.
Evaluate an expression against the live context — the same language configs use, same scopes.
Where this node sits in the config, e.g.
["nodes", 1, "if", 0, "flow", 0] — for logging.
The id of the current run.
Deterministic idempotency key of this logical step
execution: runId + node path + loop indices. It is
identical across retry attempts and across
restore() — use it to dedupe side effects under
at-least-once delivery.
Retry attempt index, 0-based.
Freeze the run until an external event — see suspend(). Throws a control marker; never returns.
registerCondition(name, predicate)
A named JS predicate (ctx: FlowContextSnapshot) => boolean.
Configs reference it as "$name" anywhere a condition is
expected.
b.registerCondition("isPremiumUser", (ctx) => {
const user = (ctx.steps.loadUser as { user?: { plan?: string } })?.user;
return user?.plan === "premium";
})
registerFlow(name, config)
A named flow — runnable via run("name", …) and
referenceable from subflow
nodes, hooks, guards and background entries. Registered flows are
validated at createFlowRunner() (forward references
between named flows work at runtime; the type system only sees names
registered earlier in the chain).
registerGuard, registerHook, registerSource, registerBackgroundFlows
b
// named guard — referenced from FlowConfig.guards (see Guards)
.registerGuard("hasSession", {
validate: "input.sessionId != null",
onFailure: "redirectToLogin",
})
// named entry point — see Hooks
.registerHook("onUserLogin", [
{ when: "input.user.plan === 'premium'", flow: "premiumOnboarding" },
{ flow: "defaultOnboarding" }, // unconditional fallback
])
// reactive data source — see Background flows
.registerSource("cart", {
getSnapshot: () => cartSignal.value,
subscribe: (cb) => effect(cb),
})
.registerBackgroundFlows("cartWatchers", [
{ when: "sources.cart.items.length > 0 && !sources.cart.reserved", flow: "reserveStock" },
])
The language
Expression language#
Configs are JSON, and JSON cannot carry callbacks — so all dynamic reads are string expressions. They are never compiled to JavaScript: the engine tokenizes them, parses a small AST and interprets it. What the grammar can express is the entire security boundary.
Grammar
| Feature | Examples |
|---|---|
| Literals | 42, 1.5, 'text', "text", true, false, null, undefined |
| Member access | steps.loadUser.user.name, vars.items[0], vars.items[vars.pick]; ?. is accepted (member access is always safe — a missing branch yields undefined, never a TypeError) |
| Unary | !ready, -vars.delta |
| Arithmetic | + - * / % |
| Comparison | === !== == != < <= > >= |
| Logic | && || ! ?? |
| Ternary | a ? b : c |
| Method calls | whitelist only — strings: includes startsWith endsWith indexOf lastIndexOf slice toLowerCase toUpperCase trim charAt at split; arrays: includes indexOf lastIndexOf slice join at concat; numbers: toFixed toString |
concat is the one addition over the sibling language
in preact-signal-formly: the language has no array
literals and no mutating methods, so without it there would be no
way to accumulate loop results through
assign. It is pure and
bounded by its operands.
Scopes — which roots exist where
| Site | Available roots |
|---|---|
| any flow node | input, steps, vars |
| loop body | + loop — loop.index (0-based), loop.count, loop.item (forEach), plus as-aliases of every enclosing loop |
retry.when | + error (error.kind, error.message, error.stepId, error.path) |
catch body | + error — the intercepted FlowError, without cause |
background when / input | sources only — sources.<name> is the source's snapshot |
hook when / input | input only — the hook call's input |
guard validate / skipWhen | input, vars |
Using a root that does not exist at a site is a config error,
caught statically by validation and loudly
at runtime — a typo like stpes.a never silently becomes
undefined.
Security
- No
eval, nonew Function— expressions from a backend cannot become remote code execution. - No assignment, no
new, no reaching globals —globalThis,fetch,windoware unreachable whatever the expression says. __proto__,constructorandprototypeare blocked on read.- Methods resolve from the built-in prototypes and are identity-checked against the receiver — a function smuggled into your data is not callable.
- Amplifier methods (
repeat,padStart,padEnd) are deliberately absent: with untrusted input they are a denial-of-service primitive. - Parsed ASTs are cached by source string, with a bounded cache.
$predicates
Anywhere a condition is expected, a string starting with
$ references a predicate registered with
registerCondition. Expressions cannot start with
$; unknown names are a validation error
(unknown-condition).
{ "when": "$isPremiumUser" }
The config
Flow nodes#
A flow is { nodes, output?, tag?, guards?, queue? }.
Nodes execute in order; between any two nodes the engine checks
pause/cancel and commits a checkpoint.
output is an expression producing the flow's result
(default: the whole context snapshot); tag labels the
flow for finish unwinding;
queue serialises runs (see Queues).
step — run a registered step
{
"step": "loadUser",
"id": "owner",
"input": { "id": "input.userId", "source": "'mobile-app'", "limit": 10 },
"config": { "withProfile": true },
"timeout": 5000,
"retry": { "attempts": 2, "delay": 300, "backoff": "exponential" },
"onError": "fail"
}
Which handler to call. Autocompleted and checked by TypeScript for inline configs; checked by validation for JSON.
The key under context.steps. Lets the same step run twice in one flow ("owner", "reviewer") without overwriting.
One rule: every string leaf is an expression.
A whole-value mapping is a single expression
("input": "steps.prev"); an object/array template
recurses; numbers, booleans and null pass
verbatim; a literal string is written as an expression string
literal — "'mobile-app'".
Static configuration, passed to the handler as the second argument. Its type is looked up from the registry by step id.
On the deadline the step-scoped ctx.signal aborts, the handler's eventual result is ignored, and the error has kind: "timeout". Defaults to FlowRunnerOptions.defaultStepTimeout.
attempts — additional tries after the first;
delay ms between tries (default 0);
backoff: "exponential" doubles it each attempt;
when — an expression over { error },
retry only while true. Timeouts retry like any other error.
After retries are exhausted: "fail" lets the error
fly (into the nearest try,
else the run fails); "skip" drops the step —
steps[id] is not written, a
stepError event fires with skipped: true,
and execution continues.
if — first matching branch
{
"if": [
{ "when": "$isPremiumUser", "flow": [{ "step": "showPremiumOffer" }] },
{ "when": "steps.loadUser.user.age >= 18", "flow": [{ "step": "showAdultOffer" }] }
],
"else": [{ "step": "showDefaultOffer" }]
}
Branches are checked top-down; the first truthy
when wins. No match → else; no
else → no-op. The branch array is the switch —
there is no separate switch node. A branch event
reports the matched index.
loop — times, while, forEach
{ "loop": { "times": 3 }, "flow": [{ "step": "ping" }] }
{
"loop": { "while": "steps.poll.status !== 'done'", "max": 50 },
"flow": [{ "step": "poll" }]
}
{
"loop": { "forEach": "steps.loadOrders.orders", "as": "order" },
"flow": [
{ "step": "processOrder", "input": { "id": "loop.order.id", "position": "loop.index" } }
]
}
times— a fixed number of iterations.while— the condition is checked before each iteration.maxis the runaway brake: exceeding it fails the run withkind: "loop". The validator warns (while-without-max) when it is missing.forEach— an expression producing an array.null/undefined→ zero iterations (consistent with safe member access); any other non-array →kind: "loop"failure. The array is captured once before the first iteration.
Inside the body the loop root exposes
loop.index, loop.count
(times / forEach length; absent for while) and loop.item
(forEach). as gives the item an alias —
loop.order above. In nested loops
index/count/item always belong
to the nearest loop, while the as-aliases of
outer loops stay visible (inner wins on a name clash):
{
"loop": { "forEach": "steps.loadUsers.users", "as": "user" },
"flow": [{
"loop": { "forEach": "loop.user.orders", "as": "order" },
"flow": [{
"step": "sync",
"input": { "userId": "loop.user.id", "orderId": "loop.order.id" }
}]
}]
}
Step outputs are overwritten each iteration — the last one stays in
steps. Accumulate explicitly through
assign + concat, seeding the initial
value via FlowRunOptions.vars (the language has no
array literals):
runner.run("searchAll", { query }, { vars: { items: [], page: 0 } });
{
"loop": { "while": "vars.page < steps.search.totalPages", "max": 100 },
"flow": [
{ "step": "search", "input": { "query": "input.query", "page": "vars.page" } },
{ "assign": {
"items": "vars.items.concat(steps.search.items)",
"page": "vars.page + 1"
} }
]
}
parallel — concurrent branches
{
"parallel": [
[{ "step": "loadUser", "input": { "id": "input.userId" } }],
[{ "step": "loadSettings" }],
[{ "step": "loadNotifications" }]
],
"settle": "all"
}
- Each branch runs on a child context: a shared readonly view of
input/steps/varsas of node start; its writes stay local until the join, then merge into the parent. - Branches may not write the same step ids or vars — the validator rejects the config (
duplicate-parallel-write) instead of leaving you a data race. settle: "failFast"(default): the first branch error aborts the remaining branches' signals; nothing merges; the run fails withkind: "parallel"andbranchErrors.settle: "all": every branch runs to completion. Writes of successful branches merge; if anything failed the node then throws with the completebranchErrors. Wrap it intryfor "do what you can, report the rest": thecatchseeserror.branchErrorswhile the successful outputs are already insteps.cancel()beats both modes — all branches abort immediately.finishwithout a tag ends only its branch; with an outer flow's tag the branch completes, the others are awaited, and the unwind continues after the join.
subflow — a flow as a function
{ "subflow": "checkout", "input": { "cartId": "steps.loadCart.id" }, "as": "checkout" }
A subflow is a function, not a macro: it gets its
own context — input is the resolved mapping (default:
the parent's input), steps and vars start
empty — and sees nothing else of the parent. Its output (the
config's output expression, or its whole context) is
stored under steps[as]. A failure inside surfaces as
kind: "subflow" with the inner error as
cause. subflow accepts a registered flow
name or an inline config.
assign — transform data safely
{
"assign": {
"total": "steps.calcA.value + steps.calcB.value",
"user.fullName": "steps.loadUser.user.firstName + ' ' + steps.loadUser.user.lastName"
}
}
Writes only under vars — keys are
dot-paths relative to it. input and steps
are read-only for configs: the provenance of step outputs cannot be
forged (reserved-root validation error). Assignments run
in key order — later ones see earlier results. Writes are
copy-on-write, which is what keeps already-committed
checkpoints intact.
finish — early exit
{ "finish": true }
{ "finish": { "tag": "checkout", "output": "vars.total" } }
- Without a tag — completes the current flow (the nearest FlowConfig) with status
completed. - With a tag — unwinds the subflow stack to the nearest flow whose
tagmatches and completes that flow; its result carriesfinishedBy: tag. Labeled-break semantics. A tag that matches nothing completes the root run. outputoverrides the finished flow's output; the expression is evaluated in the context of the flow where thefinishnode stood.
break / continue
{
"loop": { "forEach": "steps.loadOrders.orders", "as": "order" },
"flow": [
{ "step": "processOrder", "input": { "id": "loop.order.id" } },
{ "if": [
{ "when": "steps.processOrder.status === 'rate-limited'", "flow": [{ "break": true }] },
{ "when": "steps.processOrder.status === 'skipped'", "flow": [{ "continue": true }] }
] }
]
}
break exits the nearest loop; continue
skips to the next item / iteration / condition re-check. Target an
outer loop by label:
{
"loop": { "forEach": "steps.loadUsers.users", "as": "user" }, "label": "users",
"flow": [{
"loop": { "forEach": "loop.user.orders" },
"flow": [
{ "if": [{ "when": "loop.item.blocked", "flow": [{ "break": { "label": "users" } }] }] }
]
}]
}
Both are loop-scoped: they cannot cross a subflow or a
parallel branch boundary (that is
finish's job). Misplacement
is a static error: orphan-break,
unknown-loop-label.
try / catch / finally
{
"try": [
{ "step": "reserveStock", "input": { "items": "vars.items" } },
{ "step": "chargeCard", "input": { "amount": "steps.reserveStock.total" } }
],
"catch": [
{ "step": "reportError", "input": { "message": "error.message", "failedStep": "error.stepId" } },
{ "step": "releaseStock" }
],
"finally": [
{ "step": "closePaymentSession" }
]
}
- What is caught: any error that would otherwise fail the run — a step error after retries, a timeout, a broken expression, a subflow/parallel/loop failure. Order for a step:
retry→onError(a skipped step throws nothing) → nearesttry→ the run fails. - What is not: cancel and pause (not errors), guard rejection (happens before nodes), and control flow —
finish/break/continuepass throughcatchuntouched, but do runfinally. catchscope: theerrorroot — the intercepted FlowError withoutcause:error.kind,error.message,error.stepId,error.path,error.branchErrors.- A
catchthat completes cleanly swallows the error; execution continues after thetrynode. Re-throwing = throwing from a step insidecatch. An error incatchflies upward. finallyalways runs — after success, aftercatch, and during any unwind through the node. An error insidefinallyreplaces the pending outcome, exactly like the language.- Both sections are optional but at least one is required (
bad-nodeotherwise).try+finallyis the "cleanup without swallowing" pattern. - The cancel exception: on
cancel()thefinallydoes not run — cancellation is immediate and terminal; cleanup on cancel belongs to step handlers via theirAbortSignal.
At runtime
Running flows#
// by name, inline config, or a name that arrived later via applyConfig
const handle = runner.run("welcome", { userId: "42" }, {
onEvent: (e) => log(e), // this run only
signal: abortController.signal, // external abort, linked to the run
vars: { items: [] }, // seed vars before the first node
queue: "cart", // overrides FlowConfig.queue
});
run() validates synchronously (throwing
FlowValidationError on a broken config) and returns the
handle immediately; execution proceeds in the background.
The handle
Unique per run; preserved across restore().
Never rejects. Every ending — completion, failure, cancel, guard rejection, suspension — is a value. Every call returns the same promise.
"queued" | "running" | "paused" | "suspended" | "cancelled" | "completed" | "failed" | "rejected" — live.
The state as of the last committed checkpoint — handy in beforeunload. undefined once the run is terminal.
Immediate and terminal: aborts the run's AbortSignal (steps that honour it stop their I/O), discards the in-flight step's result, resolves the result with { status: "cancelled" }. Idempotent. A queued run leaves the queue without executing.
Pause parks the run at the next node boundary — the in-flight step completes first; I/O is never interrupted. Parallel branches park at their own boundaries. No-ops in states where they are meaningless.
FlowResult
type FlowResult<Output> =
| { status: "completed"; output: Output; context: FlowContextSnapshot; finishedBy?: string }
| { status: "rejected"; guard: string; context: FlowContextSnapshot }
| { status: "cancelled"; reason?: unknown; context: FlowContextSnapshot }
| { status: "failed"; error: FlowError; context: FlowContextSnapshot }
| { status: "suspended"; reason?: string; snapshot: FlowRunSnapshot; context: FlowContextSnapshot };
completed—outputis the flow'soutputexpression (or the whole context).finishedByis set when a taggedfinishended the flow early.rejected— a guard said no. Not an error, not a completion.suspended— the run left this process viactx.suspend(); persistsnapshotandrestore()it later.
FlowError
interface FlowError {
kind: "step" | "timeout" | "expression" | "subflow" | "parallel" | "loop";
message: string;
path: FlowPath; // where in the config it happened
stepId?: string;
cause?: unknown; // the original thrown value (not serialized)
branchErrors?: FlowError[]; // kind "parallel": every failed branch
}
Queues#
By default different runs execute in parallel —
every run() is independent. Sequential execution is
opt-in through named FIFO queues, at two levels (the run option
overrides the config):
// declaratively, in the flow config (serialisable — a backend can decide):
b.registerFlow("syncCart", { queue: "cart", nodes: [/* … */] });
// or per run:
runner.run("syncCart", input, { queue: "cart" });
- Runs sharing a
queueid execute strictly one at a time, inrun()call order. Different queues — and runs without one — never block each other. - While waiting, a run has
status: "queued"and aqueuedevent fires. Guards are checked after the queue is acquired (conditions may have changed while waiting). cancel()while queued leaves the queue instantly without executing;pause()keeps the position and parks the run before its first node once its turn comes.subflownodes and parallel branches never enter queues — only top-level runs are serialised.- Do not
await runner.run()into the same queue from inside a step — that is a self-deadlock. Compose flows withsubflowinstead.
The backend is pluggable. The engine needs one method —
FlowQueueAdapter:
interface FlowQueueAdapter {
run<T>(id: string, fn: () => T | Promise<T>): Promise<T>;
}
// default: a private in-process FIFO (createExecutionBlocker()).
// share queues with non-flow code:
import { createExecutionBlocker } from "@dmytromykhailiuk/execution-blocker";
const blocker = createExecutionBlocker();
const runner = createFlowRunner(setup, { queueAdapter: blocker });
await blocker.run("cart", () => updateCartOutsideFlows()); // same queue as queue:"cart" runs
// or pass a Redis-backed adapter to make queues cluster-wide — see Backend example
Guards#
Declarative preconditions, checked over { input, vars }
before the first node of a run. The old engine called them flow
validators.
b.registerGuard("hasSession", {
validate: "input.sessionId != null", // the guard passes when true
skipWhen: "input.isGuestCheckout", // when true, the guard is skipped entirely
onSuccess: "trackAccess", // fire-and-forget side runs
onFailure: "redirectToLogin",
});
b.registerFlow("checkout", {
guards: ["hasSession", { validate: "vars.featureEnabled === true" }], // names or inline
nodes: [/* … */],
});
- Guards run in order. The first failing one launches its
onFailure(as a separate run) and the main run resolves{ status: "rejected", guard: name }. - Only when all pass do their
onSuccessflows launch, and the flow proceeds. - Guards are re-checked on a fresh run only —
restore()skips them (the run had already started).
Hooks#
Named entry points: an array of { when?, flow, input? }
entries, checked top-down against { input }. Exactly
one — the first match — runs; an entry without
when is the unconditional fallback.
b.registerHook("onUserLogin", [
{ when: "input.user.plan === 'premium'", flow: "premiumOnboarding" },
{ when: "$isReturningUser", flow: "welcomeBack" },
{ flow: "defaultOnboarding" },
]);
const handle = runner.runHook("onUserLogin", { user });
// undefined when nothing matched (or the hook is not registered) — nothing ran
if (handle) await handle.getResult();
Background flows#
Reactive triggers: "when this condition over external data becomes true — run that flow". The engine knows nothing about reactive libraries; you hand it a minimal source and it subscribes:
interface ExternalSource<T> {
getSnapshot(): T;
subscribe(onChange: () => void): () => void;
}
// signals:
b.registerSource("cart", { getSnapshot: () => cartSignal.value, subscribe: (cb) => effect(cb) });
// redux:
b.registerSource("session", { getSnapshot: () => store.getState().session, subscribe: (cb) => store.subscribe(cb) });
b.registerBackgroundFlows("cartWatchers", [
{
when: "sources.cart.items.length > 0 && !sources.cart.reserved",
flow: "reserveStock",
input: { "items": "sources.cart.items" },
mode: "edge", // default
concurrent: "skip", // default
},
]);
runner.startBackgroundFlows("cartWatchers");
runner.stopBackgroundFlows("cartWatchers");
runner.stopAllBackgroundFlows();
Evaluated over { sources } — each key is a registered source's current getSnapshot() — on every change notification.
edge triggers only on the false→true transition (including a condition that is already true at start); every fires on each change notification while true.
When the previously triggered run is still active: skip ignores the new trigger; restart cancels it and launches a fresh run.
Input for the triggered run; defaults to the whole sources snapshot.
FIFO queue for the triggered runs; overrides the flow config's own queue.
startBackgroundFlows(groupId) is idempotent (restarting
re-subscribes); stop unsubscribes and cancels the
group's active triggered runs. After
applyConfig(), running
groups whose entries changed are restarted on the new version.
Durability
Persistence#
A run's state is plain JSON. After every completed node the engine
commits a checkpoint snapshot; your app stores it
wherever it likes — localStorage, Redis, a database — and after a
refresh, a crash or on another instance,
restore(snapshot) continues the run from that exact
position.
The engine deliberately ships no storage of its own — it hands you snapshots and takes them back, and where they live in between is a three-method decision your app makes once. The same loop backs every environment: localStorage in a browser (see Frontend example), Redis or a database on a server (see Backend example), a file, an S3 bucket, or an in-memory map in tests.
Checkpoints
// Any storage works — three operations is the whole contract:
interface SnapshotStore {
save(runId: string, snapshot: FlowRunSnapshot): Promise<void>;
delete(runId: string): Promise<void>;
loadAll(): Promise<FlowRunSnapshot[]>;
}
const runner = createFlowRunner(setup, {
onEvent: (e) => {
if (e.type === "checkpoint") void store.save(e.runId, e.snapshot);
if (e.type === "flowEnd") void store.delete(e.runId); // terminal — snapshot no longer needed
if (e.type === "suspended") void store.save(e.runId, e.snapshot); // NOT terminal — keep it
},
});
// on process start: continue everything unfinished
for (const snapshot of await store.loadAll()) {
runner.restore(snapshot);
}
- A checkpoint commits after every completed node — a step's output written, an
assignapplied, a branch chosen, a loop iteration finished, a parallel branch done. Between two checkpoints there is exactly one node of progress to lose. - The
checkpointevent carries the full snapshot, so saving is a plain overwrite byrunId— no diffing, no ordering concerns; the last write wins and is always a consistent state. handle.getSnapshot()returns the same state on demand (as of the last committed checkpoint) — for save-on-shutdown hooks:beforeunloadin a browser,SIGTERMin a worker.flowEndfires only on terminal endings (completed/failed/cancelled/rejected) — that is your signal to delete the stored snapshot. Asuspendedrun emits its own event instead, because its snapshot is still needed.- Checkpoints are synchronous in-memory work (a shallow context clone plus a cursor clone); the I/O of persisting them is yours and can be throttled — skipping intermediate saves only widens the at-least-once replay window, it never corrupts anything.
- Every run is persistable, however it was started —
run(),runHook(), a guard side-flow or a background trigger.
The snapshot
You never build or edit one — treat it as a JSON blob keyed by
runId. But it is readable, and understanding it helps.
Here a process died — a page refresh, a worker crash, a deploy —
on iteration 2 of a paging loop, right after
fetchPage committed:
{
"version": 1,
"runId": "run_mf83k_1_a9x2",
"flowName": "syncOrders",
"createdAt": 1786309484512,
"status": "running",
"queue": "orders",
"context": {
"input": { "userId": "42" },
"steps": {
"loadOrders": { "totalPages": 5, "total": 112 },
"fetchPage": { "items": [{ "id": "o-51" }, { "id": "o-52" }] }
},
"vars": { "items": [{ "id": "o-1" }, { "id": "o-2" }], "page": 2 }
},
"cursor": [
{ "next": 1 },
{ "next": 1, "enter": { "kind": "loop", "index": 2 } }
],
"config": { "queue": "orders", "nodes": [ /* the full syncOrders config */ ] },
"flows": {}
}
cursor— a stack of frames, root first: "at root node 1 (the loop); inside its body, iteration 2, next body node 1". Frames also record chosen if-branches, try sections, subflow contexts and completed parallel branches. The shape is internal but stable within a major version.config+flows— the flow config and every named subflow it references, embedded. The snapshot is self-contained on the declarative side: restore does not care whatapplyConfighas done since; only the code registries (steps, predicates, sources) must still provide the referenced names.- Serialisability is your side of the contract: step outputs,
inputandvarsmust be JSON-serialisable if you use persistence. The engine does not enforce it — without persistence any values are fine.
restore()
const handle = runner.restore(snapshot, options?); // FlowRunOptions
- Validates the snapshot version and that every step / predicate / source its configs reference is registered —
FlowValidationErrorotherwise. - Continues from
cursoron the storedcontext, with the same runId; aflowRestoreevent fires. - A
pausedsnapshot restores paused — the app decides when tohandle.resume().queued/runningre-enter their queue (if any) or start immediately. Guards are not re-checked. - At-least-once: a node that started but had not committed its checkpoint (the process died mid-step) runs again after restore. Steps with side effects should be idempotent — that is what
ctx.executionKeyis for: it is identical across retries and across restores, so an idempotency header, aSET NXkey or a unique DB constraint dedupes the replay. parallelis branch-atomic: a checkpoint commits when each branch completes; on restore, done branches (including branches settled with an error undersettle: "all") do not re-run — unfinished ones restart from their beginning.- Two processes restoring the same snapshot will both run it. The engine is environment-agnostic (browser and server), so double-restore locking is deliberately the application's job — Web Locks in a browser, a distributed lock on a backend.
suspend() — durable waits
For waits measured in minutes or hours — a message-queue reply, a
human approval, a webhook — holding a process on await
wastes memory, a queue slot and (on a backend) a distributed lock.
ctx.suspend(reason?) freezes the run and releases the
process:
- The run ends in this process with
status: "suspended";getResult()resolves with a finalsnapshot; asuspendedevent fires (flowEnddoes not — do not delete the stored snapshot). - Suspend is neither an error nor an unwind:
try/catchdoes not intercept it andfinallydoes not run — the run is frozen at this node, and thefinallywill run after restore, on the normal path. - The queue slot is released;
restore()re-enters the queue. - Restore re-executes the suspending step (at-least-once). The canonical pattern: check an external store by
ctx.executionKeyfirst — return the result if it arrived, suspend again if not.
b.registerStep("awaitPayment", async (input: { orderId: string }, _cfg: void, ctx) => {
const reply = await redis.get(`reply:${ctx.executionKey}`);
if (reply) return JSON.parse(reply);
// messageId = executionKey → the broker/consumer dedupes the re-publish after restore
await mq.publish("payments", { orderId: input.orderId, replyTo: ctx.executionKey });
ctx.suspend("waiting for payment reply");
});
// the reply consumer, possibly on another instance:
// await redis.set(`reply:${msg.replyTo}`, JSON.stringify(payload));
// const snapshot = JSON.parse(await redis.get(`flow:${msg.runId}`));
// runner.restore(snapshot); // the step re-runs and finds the reply
applyConfig#
The declarative layer — everything that is JSON — can be loaded or replaced at any point of the runner's life. The typical scenario: flow configs arrive from a backend after the app has started.
interface EngineConfig {
flows?: Record<string, FlowConfig>;
hooks?: Record<string, HookEntry[]>;
guards?: Record<string, FlowGuardConfig>;
backgroundFlows?: Record<string, BackgroundFlowEntry[]>;
}
const json = await fetch("/api/flow-config").then((r) => r.json());
runner.applyConfig(json); // atomic: all or nothing
runner.run("welcome", input); // new runs see the new version
- Atomic. The whole document is validated against the step/predicate/source registries first; any error →
FlowValidationErrorand no change is applied. mode: "merge"(default) upserts by name — new names appear, existing ones (including builder-registered) are overridden."replace"resets the declarative layer to "builder registrations + this document" — the way to delete names.- Run versioning. Every run captures the registry as of its start; active and paused runs finish on their version.
applyConfigaffects new runs only — a flow never changes under a run's feet. - Started background groups whose entries changed are restarted on the new version; stopped ones are simply updated.
- Steps, predicates and sources are code — they cannot appear in the document and cannot change after
createFlowRunner(). - Names added at runtime are not in the type-level union —
run()accepts them via itsstringfallback, and an unknown name is a synchronousFlowValidationError.
Events#
Two subscription levels: runner.subscribe(listener) /
FlowRunnerOptions.onEvent see every run of the runner;
FlowRunOptions.onEvent sees one run. A listener that
throws is contained — it can never break a run.
| Event | Payload (besides runId) | When |
|---|---|---|
queued | flowName?, queue | the run is waiting for its FIFO queue |
flowStart | flowName?, input | a fresh run begins (after leaving the queue) |
flowRestore | flowName?, snapshotCreatedAt | a run continues from a snapshot |
checkpoint | snapshot | after every completed node — persist it |
stepStart | stepId, path, input | a step begins |
stepEnd | stepId, path, output, durationMs | a step succeeded |
stepError | stepId, path, error, willRetry, skipped | a step attempt failed |
branch | path, matchedIndex (number | "else" | null) | an if chose its branch |
loopIteration | path, index | a loop iteration begins |
paused / resumed | — | pause() / resume() took effect |
suspended | reason?, snapshot | ctx.suspend() froze the run — keep the snapshot |
heartbeat | — | every heartbeatMs for each active run, including mid-step |
flowEnd | result | terminal endings only (completed / failed / cancelled / rejected) — safe to delete the snapshot |
One runId per run() / runHook()
call; nested subflows and parallel branches emit into the same
stream — nesting is visible in path.
Validation#
Everything TypeScript cannot see is checked before a run starts:
registerFlow configs at createFlowRunner()
(fail fast at startup), inline configs synchronously inside
run() (throwing FlowValidationError), and
backend documents explicitly:
const result = runner.validateFlow(config); // never throws
if (!result.valid) {
for (const issue of result.issues) {
console.warn(issue.severity, issue.code, issue.path.join("."), issue.message);
}
}
| Code | Severity | Meaning |
|---|---|---|
unknown-step | error | a step node names an unregistered step |
unknown-flow | error | a subflow / hook / guard / background entry references an unknown flow name |
unknown-condition | error | "$name" references an unregistered predicate |
bad-expression | error | an expression does not parse, or reads a root that does not exist at its site |
duplicate-parallel-write | error | two parallel branches write the same step id or var |
reserved-root | error | an assign key tries to write input or steps |
while-without-max | warning | a while loop has no max brake |
orphan-break | error | break/continue outside a loop, or crossing a subflow/parallel boundary |
unknown-loop-label | error | no enclosing loop carries the referenced label |
bad-node | error | malformed node — unknown shape, try without catch/finally, … |
Validation results are cached per config object and revalidated only
when the declarative registry version moves — repeated
run() calls with the same config pay nothing.
Recipes
Frontend example#
In a browser the snapshot store is localStorage and
"process start" is app init: persist on every
checkpoint, delete on flowEnd, and after a
page refresh continue everything unfinished. This is the whole
integration:
const runner = createFlowRunner(setup, {
onEvent: (e) => {
if (e.type === "checkpoint") {
localStorage.setItem(`flow:${e.runId}`, JSON.stringify(e.snapshot));
}
if (e.type === "flowEnd") {
localStorage.removeItem(`flow:${e.runId}`); // terminal — snapshot no longer needed
}
},
});
// on app init: continue everything unfinished
for (const key of Object.keys(localStorage)) {
if (!key.startsWith("flow:")) continue;
runner.restore(JSON.parse(localStorage.getItem(key)!));
}
- A run interrupted by a refresh resumes from its last committed node; the step that was in flight re-runs (at-least-once).
- Suspended runs are covered too: their final snapshot arrives through the
suspendedevent'ssnapshotfield (or theresult), and the same init loop picks them up — the waiting step re-checks its external store and suspends again if the reply has not arrived. - For an extra safety net against the tab closing mid-node, save
handle.getSnapshot()inbeforeunload. - Two tabs restoring the same snapshot will both run it — if that matters, take a Web Lock keyed by
runIdbefore callingrestore().
Operations
Backend example#
The engine is environment-agnostic; a clustered deployment with long-running steps is a composition of the primitives above. The reference shape, with RabbitMQ triggering runs and Redis holding snapshots and locks:
const runner = createFlowRunner(setup, {
heartbeatMs: 10_000,
queueAdapter: redisQueueAdapter, // optional: cluster-wide FIFO per queue id
onEvent: async (e) => {
if (e.type === "checkpoint") await redis.set(`flow:${e.runId}`, JSON.stringify(e.snapshot));
if (e.type === "suspended") await redis.set(`flow:${e.runId}`, JSON.stringify(e.snapshot));
if (e.type === "flowEnd") await redis.del(`flow:${e.runId}`, `lock:${e.runId}`);
if (e.type === "heartbeat") await redis.pexpire(`lock:${e.runId}`, 30_000); // renew the lease
},
});
// RabbitMQ triggers runs; ack after a terminal (or suspended) result
channel.consume("flows", async (msg) => {
const { flowName, input, runId } = JSON.parse(msg.content.toString());
if (!(await redis.set(`lock:${runId}`, INSTANCE_ID, "NX", "PX", 30_000))) {
return channel.ack(msg); // someone else owns this run
}
const result = await runner.run(flowName, input).getResult();
channel.ack(msg); // "suspended" is also an ack — restore continues it later
});
// a reaper finds dead runs: a stored snapshot whose lock expired
for (const key of await redis.keys("flow:*")) {
const runId = key.slice(5);
if (await redis.set(`lock:${runId}`, INSTANCE_ID, "NX", "PX", 30_000)) {
runner.restore(JSON.parse(await redis.get(key)));
}
}
- Long steps:
heartbeatfires even mid-step, so the lock lease outlives a 5-minute handler; a stale lock + an existing snapshot marks a run another instance may pick up. The interrupted node re-runs there — at-least-once, deduped byctx.executionKey. - Long waits:
ctx.suspend()frees the process entirely; the reply consumer restores the run wherever it lands. - Cross-instance queues: implement
FlowQueueAdapterover a Redis FIFO lock andqueue: "cart"becomes cluster-wide with zero config changes. - Rolling deploys: snapshots embed their flow configs, but steps are code — keep step ids stable between versions, and remove a step only after the snapshots referencing it have drained.
TypeScript#
An honest boundary: the compiler checks what it can see, the validator checks the rest.
The compiler catches
- Unknown step ids in inline configs —
stepis a union of registry keys, and each node'sconfigtype is looked up by id. - Unknown flow names in
run()/subflow(names registered earlier in the chain; runtime also resolves forward references andapplyConfignames through thestringfallback). - Step handler signatures, predicate signatures, source snapshot types.
- Registered predicate names autocomplete as
"$name"in condition slots.
Runtime only
- String expressions and input mappings — the content of strings is invisible to the type system; whether a mapping's result matches a step's input type cannot be checked statically. This is the honest limit of JSON configs; the compensation is validation plus errors that carry an exact
path. run<Input, Output>()type parameters are the caller's assertion.- Existence of
"$name"predicates and of names added viaapplyConfig.