xState as the canonical authoring surface
Statecharts replace flat DAGs. Parallel regions, hierarchical states, history, formal transition semantics, SCXML for cross-engine portability, and a free visualizer (Stately) โ all without building any of it ourselves.
Why
WDK's grammar is a flat DAG of awaits.
Each step is an await; control flow is JS itself. That works for linear pipelines and breaks down everywhere else โ parallel regions, hierarchical states, history, formal verification. xFlow adopts xState as the canonical inner shape so we get those for free.
What xState gets you
- ยท Parallel regions (multiple states active simultaneously)
- ยท Hierarchical states (compound/atomic/final/history)
- ยท Formal transition semantics (SCXML-aligned)
- ยท Stately Studio for visual authoring + simulation
- ยท SCXML serialization for cross-engine handoff
- ยท An ecosystem of test utilities and inspectors
What xFlow adds
- ยท Statecharts become content-addressed registry entries
- ยท Round-trip codec to/from WorkflowDefinition
- ยท Per-step claim/placement/retry config via
meta.xflow - ยท Substrate-pluggable execution; no xstate runtime dep
Codec
fromXState โ author once, run anywhere.
Take any xstate v5 machine config and produce a WorkflowDefinition the runtime can drive.
import { fromXState } from "@decoperations/xflow-xstate"
const machine = {
id: "shop.checkout",
initial: "load",
states: {
load: { invoke: { src: "cart.load", onDone: "price" } },
price: { invoke: { src: "cart.price", onDone: "charge" } },
charge: { invoke: { src: "payments.charge", onDone: "done", onError: "review" } },
review: { invoke: { src: "human.review", onDone: "done" } },
done: { type: "final" },
},
}
const workflow = fromXState(machine, { id: "shop.checkout", version: "1.0.0" })
// โ WorkflowDefinition with steps + links derived from invoke / onDone / onErrorCodec (inverse)
toXState โ round-trip preserves the topology.
The WorkflowDefinition coming out of fromXState round-trips back to a machine config that you can hand to Stately's visualizer or to another engine.
import { toXState } from "@decoperations/xflow-xstate"
const machine = toXState(workflow)
// Round-trip preserves: step ids, types, link topology,
// and per-step config (claim, placement, retry, sideEffects, timeoutMs)
// via meta.xflow on each state.Parallel
Parallel regions become concurrent steps.
A `type: 'parallel'` region flattens into independent steps that share a workflow.start link. xFlow's runtime claims each on whichever node is eligible โ no coordination required.
// Parallel regions flatten into independent steps that fire concurrently.
const machine = {
id: "review",
initial: "review",
states: {
review: {
type: "parallel",
states: {
human: { invoke: { src: "review.human" } },
fraud: { invoke: { src: "review.fraud" } },
},
},
done: { type: "final" },
},
}
const wf = fromXState(machine, { id: "review", version: "1.0.0" })
// โ wf.steps: { "review.human": {...}, "review.fraud": {...} }
// โ wf.links: workflow.start โ review.human AND workflow.start โ review.fraud
// Both branches start together; the runtime claims each on whichever node is eligible.Hierarchical
Compound states namespace their children.
Nested states get path-namespaced step ids ('main.step1'). Incoming links resolve through to the compound state's initial child automatically.
// Compound (nested) states delegate to their initial substate.
const machine = {
states: {
before: { invoke: { src: "noop", onDone: "main" } },
main: {
type: "compound",
initial: "step1",
states: {
step1: { invoke: { src: "main.step1", onDone: "step2" } },
step2: { invoke: { src: "main.step2" } },
},
},
},
}
// Step ids namespace by path: "main.step1", "main.step2".
// "before.onDone: main" resolves through to "main.step1" (the initial child).Per-step config
meta.xflow rides alongside each state.
Claim mode, placement policy, retry policy, side-effect kind, timeout โ anything xFlow needs that xstate doesn't model. Standard xstate consumers ignore it; xFlow's codec extracts it.
// Per-step xFlow config rides as meta.xflow on each xstate state.
const machine = {
id: "deploy",
initial: "build",
states: {
build: {
invoke: { src: "ci.build", onDone: "deploy" },
meta: {
xflow: {
claim: { mode: "lease", ttlMs: 30_000 },
retry: { maxAttempts: 3, backoff: { kind: "exponential", initialMs: 200, maxMs: 5000 } },
timeoutMs: 120_000,
},
},
},
deploy: { invoke: { src: "ci.deploy", onDone: "done" } },
done: { type: "final" },
},
}Portability
SCXML 1.0 round-trip.
Any xstate machine serializes to W3C SCXML 1.0 and back. atomic / compound / parallel / final states + invoke onDone/onError + named transitions + meta.xflow all survive. Use it to hand off flows to non-TS engines or archive them in a portable format.
import { toSCXML, fromSCXML } from "@decoperations/xflow-xstate"
const xml = toSCXML(machine)
// <?xml version="1.0" encoding="UTF-8"?>
// <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" name="shop.checkout" initial="load">
// <state id="load">
// <invoke type="action" src="cart.load"/>
// <transition event="done.invoke" target="price"/>
// </state>
// ...
// <final id="done"></final>
// </scxml>
const back = fromSCXML(xml)
// Round-trip preserves: states (atomic / compound / parallel / final),
// invoke + onDone/onError, named transitions, meta.xflow overrides
// (carried under <meta xmlns:xflow="..."><xflow:overrides>{json}</xflow:overrides></meta>).Registry
xstate-v5 as a canonical registry format.
When the xstate machine *is* the definition, skip the WorkflowDefinition shape entirely. xstateToFlowDef wraps a machine as a FlowDef with format='xstate-v5'; extensions (parallel-states, history-states) are inferred from the machine shape.
import { xstateToFlowDef, flowDefToXState } from "@decoperations/xflow-xstate"
import { contentHash } from "@decoperations/xflow-registry"
// Wrap an xstate machine as a registry-shaped FlowDef.
// format: "xstate-v5" โ the machine *is* the canonical definition.
const draft = xstateToFlowDef(machine, { id: "shop.checkout", version: "1.0.0" })
draft.manifest.contentHash = await contentHash(draft)
// Publish via fsRegistry / s3wormRegistry / chainResolvers โ same as any other entry.
// Extensions (parallel-states, history-states) auto-inferred from the machine shape.
// Resolve later:
const back = flowDefToXState(entry) // โ XStateMachineLike, ready for Stately Studio.Next