Authoring Workflows
Writing a workflow is writing an async function. The rules below are the places where that intuition breaks down — mostly because your function can be stopped and resumed weeks later, on a different machine.
defineWorkflow(options, execute). The name, version, and input schema go in
options; the logic goes in the async execute(flow) function, and whatever it
returns becomes the result of the run.
A deployed version is frozen. Change the behaviour by publishing a new version — runs already in flight keep executing the version they started on.
Validate the input before the first step runs. Use any Standard Schema validator; the SDK ships Valibot if you don’t already have a preference.
Bad input means the run never starts. That’s deliberate: you would otherwise end up with a half-executed process whose events describe work you didn’t want done.
When a run resumes, IdentityFlow rebuilds its state by replaying the recorded events. That only works if your code produces the same answer every time.
So, in workflow code — not inside a step, but in the function body itself — do not read the clock, generate random values, or call the network or a database. Anything non-deterministic belongs inside a step, whose result is recorded once and replayed from the record afterwards.
Two habits follow from this: name every step, always the same way, and make each step safe to run twice.
The event stream is append-only. A restart resumes from what was recorded; it never edits an earlier event. See Errors and Retries for what that means for calls that leave the process.
flow.start() launches a child run. Give every child step a stable name, for
the same reason as above.
When independent steps can run at the same time, use the ordinary Promise
methods — there is no special API. IdentityFlow tracks the Promise returned
directly by flow.dialog(), flow.sleep(), flow.do(), flow.request(),
and flow.start(), and the method you pick decides what happens to the ones
still running when the combined Promise settles.
Promise.all, Promise.allSettled, Promise.any, and Promise.race work the
way you already expect: same ordering, same rejections. What IdentityFlow adds
is that cancellation and suspension survive a restart.
| Method | When the combined Promise settles | Active direct Flow Step Promises |
|---|---|---|
Promise.all | every input fulfills, or the first input rejects | canceled after an ordinary rejection |
Promise.allSettled | every input has a business outcome | never canceled |
Promise.any | the first input fulfills, or every input rejects | canceled after the first fulfillment |
Promise.race | the first input ordinarily fulfills or rejects | canceled after that first settlement |
One distinction is worth pausing on, because it is the thing people get wrong. A step that is waiting — for a person, for a timer — is not a step that failed. Engine suspension cancels no sibling: it pauses the whole run until the waiting step can continue, and it never shows up as a rejected Promise.
When one of the Promise methods above does cancel a step, that step is
persisted as CANCELED before you see the result.
Use Promise.all when later work needs every result. Result order follows input
order. An ordinary rejection cancels the other active direct Flow Step
Promises because the combined Promise can no longer fulfill.
const [recipient, role, locale] = await Promise.all([ flow.do('load recipient', () => ({ id: flow.params.recipientId })), flow.do('load role', () => ({ id: flow.params.roleId })), Promise.resolve('en'),]);The plain Promise.resolve() in that list behaves like any other Promise. The
engine only knows about the two flow.do() Promises; it will never report the
plain one as canceled.
Keep every business outcome with Promise.allSettled
Section titled “Keep every business outcome with Promise.allSettled”Use Promise.allSettled when each item may succeed or fail independently.
Inspect every rejected result and turn it into a deliberate business outcome.
allSettled never cancels another member.
const assignmentResults = await Promise.allSettled( flow.params.assignments.map((assignment, index) => flow.do(`apply assignment ${index}`, () => { if (assignment === 'blocked') throw new Error('Assignment is blocked'); return assignment; }), ),);
const failedAssignments = assignmentResults.flatMap((result, index) => result.status === 'rejected' ? [flow.params.assignments[index]] : [],);Suspension travels as control flow, so it never turns up among the settled
results. This is why a broad catch around the whole composition is a bad
idea — it swallows the signal that pauses the run. If you catch an AbortError
somewhere to clean up, rethrow it.
Accept the first fulfillment with Promise.any
Section titled “Accept the first fulfillment with Promise.any”Use Promise.any when any one success is enough. The first success wins and
IdentityFlow cancels the still-running steps. If everything rejects you get the
normal AggregateError — no winner is invented.
const firstApproval = await Promise.any([ flow.do('manager approval', () => 'manager'), flow.do('owner approval', () => 'owner'),]);Accept the first settlement with Promise.race
Section titled “Accept the first settlement with Promise.race”Use Promise.race when the first outcome decides, success or failure alike.
Pass the step Promises directly if you want the losers reliably canceled.
const firstDeadline = await Promise.race([ flow.sleep('short deadline', '15 minutes', 'short'), flow.sleep('long deadline', '1 hour', 'long'),]);Both members here are step Promises, so the loser is written as CANCELED
before firstDeadline is handed back to you.
Cancellation is cooperative for flow.do(), flow.request(), and
flow.start(): IdentityFlow signals your callback and waits for it to stop
somewhere safe.
What it cannot do is undo work that already left the process. Cancellation cannot roll back an external effect, so the system you called still has to cope with a repeat.
One gotcha: only the Promise a step returns directly carries its cancellation
information. The moment you wrap it — .then(), .catch(), .finally(), a
Promise subclass, a thenable of your own — the engine loses track of it.
The exact type signatures are in Workflow API, quoted from the source.