flow-engine
flow-engine 1.0.1

Typed flow engine. JSON flows, safe expressions, durable runs.

Contents

Contents

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.

typed builder JSON-serialisable flows no eval cancel / pause / suspend checkpoints & restore FIFO queues workflow orchestration safe string expressions retry & timeouts durable execution guards & hooks background flows runtime config type-safe browser & Node zero third-party deps

Getting started

Install#

sh
npm i @dmytromykhailiuk/flow-engine
Dependencies

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.

ts
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");
Where flows come from

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#

step

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.

flow

A JSON config: an array of nodes (step, if, loop, parallel, subflow, assign, finish, break, continue, try) plus optional output, tag, guards and queue.

run context

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.

expression

A string like "steps.loadUser.user.age >= 18", parsed by the engine's own safe interpreter — never compiled to JavaScript.

predicate

A named JS condition registered with registerCondition; referenced from configs as "$name" — for logic too complex for an expression.

runner

What createFlowRunner() returns: run, runHook, restore, validateFlow, applyConfig, background flows, subscribe.

handle

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.

ts
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].

ts
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:

signalAbortSignal

Fires on cancel() and on the step's timeout. Pass it into fetch and anything else abortable.

contextFlowContextSnapshot

A readonly snapshot of { input, steps, vars } at step start.

evaluate(expr)(expression: string) => unknown

Evaluate an expression against the live context — the same language configs use, same scopes.

pathFlowPath

Where this node sits in the config, e.g. ["nodes", 1, "if", 0, "flow", 0] — for logging.

runIdstring

The id of the current run.

executionKeystring

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.

attemptnumber

Retry attempt index, 0-based.

suspend(reason?)(reason?: string) => never

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.

ts
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

ts
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

FeatureExamples
Literals42, 1.5, 'text', "text", true, false, null, undefined
Member accesssteps.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&& || ! ??
Ternarya ? b : c
Method callswhitelist 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
Why concat

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

SiteAvailable roots
any flow nodeinput, steps, vars
loop body+ looploop.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 / inputsources only — sources.<name> is the source's snapshot
hook when / inputinput only — the hook call's input
guard validate / skipWheninput, 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, no new Function — expressions from a backend cannot become remote code execution.
  • No assignment, no new, no reaching globals — globalThis, fetch, window are unreachable whatever the expression says.
  • __proto__, constructor and prototype are 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).

json
{ "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

json
{
  "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"
}
stepregistered step id

Which handler to call. Autocompleted and checked by TypeScript for inline configs; checked by validation for JSON.

idstring — default: the step name

The key under context.steps. Lets the same step run twice in one flow ("owner", "reviewer") without overwriting.

inputInputMapping

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'".

configtyped per step

Static configuration, passed to the handler as the second argument. Its type is looked up from the registry by step id.

timeoutnumber (ms)

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.

retry{ attempts, delay?, backoff?, when? }

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.

onError"fail" | "skip" — default "fail"

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

json
{
  "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

json
{ "loop": { "times": 3 }, "flow": [{ "step": "ping" }] }
json
{
  "loop": { "while": "steps.poll.status !== 'done'", "max": 50 },
  "flow": [{ "step": "poll" }]
}
json
{
  "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. max is the runaway brake: exceeding it fails the run with kind: "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):

jsonnested loops share items via aliases
{
  "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" }
    }]
  }]
}
Accumulating results

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):

ts
runner.run("searchAll", { query }, { vars: { items: [], page: 0 } });
json
{
  "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

json
{
  "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/vars as 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 with kind: "parallel" and branchErrors.
  • settle: "all": every branch runs to completion. Writes of successful branches merge; if anything failed the node then throws with the complete branchErrors. Wrap it in try for "do what you can, report the rest": the catch sees error.branchErrors while the successful outputs are already in steps.
  • cancel() beats both modes — all branches abort immediately.
  • finish without 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

json
{ "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

json
{
  "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

json
{ "finish": true }
json
{ "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 tag matches and completes that flow; its result carries finishedBy: tag. Labeled-break semantics. A tag that matches nothing completes the root run.
  • output overrides the finished flow's output; the expression is evaluated in the context of the flow where the finish node stood.

break / continue

json
{
  "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:

json
{
  "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

json
{
  "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: retryonError (a skipped step throws nothing) → nearest try → the run fails.
  • What is not: cancel and pause (not errors), guard rejection (happens before nodes), and control flow — finish/break/continue pass through catch untouched, but do run finally.
  • catch scope: the error root — the intercepted FlowError without cause: error.kind, error.message, error.stepId, error.path, error.branchErrors.
  • A catch that completes cleanly swallows the error; execution continues after the try node. Re-throwing = throwing from a step inside catch. An error in catch flies upward.
  • finally always runs — after success, after catch, and during any unwind through the node. An error inside finally replaces the pending outcome, exactly like the language.
  • Both sections are optional but at least one is required (bad-node otherwise). try + finally is the "cleanup without swallowing" pattern.
  • The cancel exception: on cancel() the finally does not run — cancellation is immediate and terminal; cleanup on cancel belongs to step handlers via their AbortSignal.

At runtime

Running flows#

ts
// 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

runIdstring

Unique per run; preserved across restore().

getResult()Promise<FlowResult<Output>>

Never rejects. Every ending — completion, failure, cancel, guard rejection, suspension — is a value. Every call returns the same promise.

getStatus()FlowRunStatus

"queued" | "running" | "paused" | "suspended" | "cancelled" | "completed" | "failed" | "rejected" — live.

getSnapshot()FlowRunSnapshot | undefined

The state as of the last committed checkpoint — handy in beforeunload. undefined once the run is terminal.

cancel(reason?)

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() / resume()

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

ts
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 };
  • completedoutput is the flow's output expression (or the whole context). finishedBy is set when a tagged finish ended the flow early.
  • rejected — a guard said no. Not an error, not a completion.
  • suspended — the run left this process via ctx.suspend(); persist snapshot and restore() it later.

FlowError

ts
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):

ts
// 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 queue id execute strictly one at a time, in run() call order. Different queues — and runs without one — never block each other.
  • While waiting, a run has status: "queued" and a queued event 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.
  • subflow nodes 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 with subflow instead.

The backend is pluggable. The engine needs one method — FlowQueueAdapter:

ts
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.

ts
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 onSuccess flows 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.

ts
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:

ts
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();
whenexpression | "$predicate"

Evaluated over { sources } — each key is a registered source's current getSnapshot() — on every change notification.

mode"edge" | "every" — default "edge"

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.

concurrent"skip" | "restart" — default "skip"

When the previously triggered run is still active: skip ignores the new trigger; restart cancels it and launches a fresh run.

inputInputMapping — scope { sources }

Input for the triggered run; defaults to the whole sources snapshot.

queuestring

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

tsthe persistence loop, over any storage
// 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 assign applied, a branch chosen, a loop iteration finished, a parallel branch done. Between two checkpoints there is exactly one node of progress to lose.
  • The checkpoint event carries the full snapshot, so saving is a plain overwrite by runId — 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: beforeunload in a browser, SIGTERM in a worker.
  • flowEnd fires only on terminal endings (completed / failed / cancelled / rejected) — that is your signal to delete the stored snapshot. A suspended run 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:

json
{
  "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 what applyConfig has done since; only the code registries (steps, predicates, sources) must still provide the referenced names.
  • Serialisability is your side of the contract: step outputs, input and vars must be JSON-serialisable if you use persistence. The engine does not enforce it — without persistence any values are fine.

restore()

ts
const handle = runner.restore(snapshot, options?);  // FlowRunOptions
  • Validates the snapshot version and that every step / predicate / source its configs reference is registered — FlowValidationError otherwise.
  • Continues from cursor on the stored context, with the same runId; a flowRestore event fires.
  • A paused snapshot restores paused — the app decides when to handle.resume(). queued/running re-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.executionKey is for: it is identical across retries and across restores, so an idempotency header, a SET NX key or a unique DB constraint dedupes the replay.
  • parallel is branch-atomic: a checkpoint commits when each branch completes; on restore, done branches (including branches settled with an error under settle: "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 final snapshot; a suspended event fires (flowEnd does not — do not delete the stored snapshot).
  • Suspend is neither an error nor an unwind: try/catch does not intercept it and finally does not run — the run is frozen at this node, and the finally will 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.executionKey first — return the result if it arrived, suspend again if not.
tsdurable wait for a payment reply
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.

ts
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 → FlowValidationError and 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. applyConfig affects 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 its string fallback, and an unknown name is a synchronous FlowValidationError.

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.

EventPayload (besides runId)When
queuedflowName?, queuethe run is waiting for its FIFO queue
flowStartflowName?, inputa fresh run begins (after leaving the queue)
flowRestoreflowName?, snapshotCreatedAta run continues from a snapshot
checkpointsnapshotafter every completed node — persist it
stepStartstepId, path, inputa step begins
stepEndstepId, path, output, durationMsa step succeeded
stepErrorstepId, path, error, willRetry, skippeda step attempt failed
branchpath, matchedIndex (number | "else" | null)an if chose its branch
loopIterationpath, indexa loop iteration begins
paused / resumedpause() / resume() took effect
suspendedreason?, snapshotctx.suspend() froze the run — keep the snapshot
heartbeatevery heartbeatMs for each active run, including mid-step
flowEndresultterminal 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:

ts
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);
  }
}
CodeSeverityMeaning
unknown-steperrora step node names an unregistered step
unknown-flowerrora subflow / hook / guard / background entry references an unknown flow name
unknown-conditionerror"$name" references an unregistered predicate
bad-expressionerroran expression does not parse, or reads a root that does not exist at its site
duplicate-parallel-writeerrortwo parallel branches write the same step id or var
reserved-rooterroran assign key tries to write input or steps
while-without-maxwarninga while loop has no max brake
orphan-breakerrorbreak/continue outside a loop, or crossing a subflow/parallel boundary
unknown-loop-labelerrorno enclosing loop carries the referenced label
bad-nodeerrormalformed 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:

tsrefresh-proof flows in the browser
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 suspended event's snapshot field (or the result), 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() in beforeunload.
  • Two tabs restoring the same snapshot will both run it — if that matters, take a Web Lock keyed by runId before calling restore().

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:

tsworker instance
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: heartbeat fires 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 by ctx.executionKey.
  • Long waits: ctx.suspend() frees the process entirely; the reply consumer restores the run wherever it lands.
  • Cross-instance queues: implement FlowQueueAdapter over a Redis FIFO lock and queue: "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 — step is a union of registry keys, and each node's config type is looked up by id.
  • Unknown flow names in run() / subflow (names registered earlier in the chain; runtime also resolves forward references and applyConfig names through the string fallback).
  • 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 via applyConfig.