Author workflows
A workflow is an async function whose progress survives a restart. On this page you save one small workflow, run its Workflow Test, and then change it. Afterwards you have a workflow the host can deploy and a test that proves its behavior. The page assumes you know how to program, but not that you write JavaScript every day.
Before you start: work in the starter, the example project delivered with IdentityFlow. Its packages, the IdentityFlow program and the Workflow Test distribution belong to the same release. The npm packages alone type-check a workflow but cannot run it or its tests. IdentityFlow is provided to customers directly. If you don’t have the starter project and the IdentityFlow program yet, write to contact@kenoxa.de.
First step: save the workflow file, then verify the result with its test. Validation and Replay rules are what you check before you change it. Parallel steps and Promise composition, further down, are optional; look up options and less frequent operations in the Workflow API reference.
Save this file as reference-approval.workflow.ts in your project’s workflow
directory. The host deploys files whose names end in .workflow.ts, among
other patterns; Install and run IdentityFlow lists them.
The host deploys only the file’s default export, so one file holds one
workflow. A definition that is only a named export is never deployed.
The file exports Directory, a binding
to a directory service, and approval, the
Workflow Definition. Its last line,
export default approval, makes approval the definition the host deploys;
the test below imports the two named exports.
import * as v from '@identity-flow/sdk/valibot';import { defineBinding, defineWorkflow } from '@identity-flow/sdk';
export const Directory = defineBinding('reference-directory', () => ({ lookup(_employeeId: string, _signal: AbortSignal): Promise<{ displayName: string }> { throw new Error('Configure a directory client for this environment'); },}));
export const approval = defineWorkflow( { name: 'reference-approval', version: '1.0.0', draft: false, schema: v.object({ employeeId: v.string() }), }, async (flow) => { const person = await flow.do('load employee', { retries: 3 }, ({ use, signal }) => use(Directory).lookup(flow.params.employeeId, signal), ); const answer = await flow.dialog( 'approve request', { schema: v.object({ approved: v.boolean() }) }, () => ({ params: { displayName: person.displayName }, assignees: [{ providerId: 'acme', subject: 'role:approver' }], }), ); return { employeeId: flow.params.employeeId, approved: answer.approved }; },);
export default approval;The Directory binding throws on purpose. It is the place where you create
your real directory client, and until you do, a run fails instead of silently
using sample data. The test below replaces it with a
fixture.
Read it in execution order:
defineWorkflowreceives an options object and a function. The object names and versions the definition and declares the schema of its input.draft: falselets the host deploy it.draftdefaults totrue, and the host skips a draft at startup with the log lineSkipping deployment of draft workflow.flow.do('load employee', …)is a step. It calls the directory and records the result.{ retries: 3 }allows up to three attempts in total if the call fails; see Errors and retries. When the workflow continues after the answer, this step returns the recorded result instead of calling the directory again.flow.dialog('approve request', …)creates a dialog and waits for a person’s answer that matches the decision schema.assigneessays who may answer: any account for which the provider with the IDacmereports the principalrole:approver. Provider IDs and principals are explained in Concepts.returnsets the result of the completed instance. A refusal can be ordinary data:approved: falseis a valid answer, not a technical failure.
The step call carries the name, the retry limit and the cancellation signal:
flow.do('load employee', { retries: 3 }, ({ use, signal }) =>- stable step name
- three attempts in total
- pass it on to the call
The dialog’s assignees name who may answer:
assignees: [{ providerId: 'acme', subject: 'role:approver' }],- provider ID
- principal that may answer
TypeScript syntax used in the example, for Go developers
| Syntax in the example | Read it as |
|---|---|
{ name: 'reference-approval', version: '1.0.0' } | An object with named fields, similar in purpose to a Go struct literal. |
async (flow) => { ... } | A function value passed as an argument. IdentityFlow calls it with the workflow context. |
await flow.do(...) | Continue when this step has a result. Waiting at a durable step can suspend the workflow; it is not a goroutine kept alive for weeks. |
({ use, signal }) => ... | A callback that takes one context object and gives two of its fields local names. |
() => ({ params: ..., assignees: ... }) | A function returning an object. The parentheses distinguish the object from a function body. |
Promise<{ displayName: string }> | TypeScript’s description of an asynchronous result. It is an object with a string field when awaited. |
throw / catch | Error propagation instead of Go’s explicit (value, error) return. If you catch broadly, rethrow AbortError; see Cancellation boundary. |
You can write your first workflows by changing names, schemas, callbacks and returned data in an example like this one. You do not need to write generic types or build an abstraction first.
Save this Workflow Test as reference-approval.workflow.case.ts. The file name
must end in .case.ts, its location must match the include pattern of your
vitest.config.ts, and its import must point to the workflow file. In the
starter, save the workflow in src/workflows/ and this test as
tests/workflows/reference-approval.workflow.case.ts, and change the import to
'../../src/workflows/reference-approval.workflow'. The starter selects
*.case.ts and *.test.ts files under tests/. A test outside those patterns
is skipped without a warning.
import { WorkflowTestError, workflowTest } from '@identity-flow/testing';import { expect, vi } from 'vitest';
import { Directory, approval } from './reference-approval.workflow';
const test = workflowTest({ workflow: approval, accounts: { requester: { providerId: 'acme', principals: ['user:requester'] }, approver: { providerId: 'acme', principals: ['role:approver'] }, },});
test('records the lookup and accepts only an eligible answer', async ({ flow, accounts, fixture,}) => { const lookup = vi.fn(() => Promise.resolve({ displayName: 'Alex Example' })); fixture(Directory, { lookup }); const instance = await flow.actAs(accounts.requester).start({ employeeId: 'e-2041' }); const dialog = await flow.waitFor(instance).toHaveDialog('approve request'); await expect( flow.actAs(accounts.requester).completeActivity(dialog, { approved: true }), ).rejects.toBeInstanceOf(WorkflowTestError); await flow.actAs(accounts.approver).completeActivity(dialog, { approved: true }); const completed = await flow.waitFor(instance).toBeCompleted(); expect(completed.data).toEqual({ employeeId: 'e-2041', approved: true }); expect(lookup).toHaveBeenCalledOnce(); const observed = await flow.observe(instance); expect(observed.steps.map(({ name }) => name)).toEqual(['load employee', 'approve request']);});
test('refuses an answer outside the schema and keeps the dialog waiting', async ({ flow, accounts, fixture,}) => { fixture(Directory, { lookup: () => Promise.resolve({ displayName: 'Alex Example' }) }); const instance = await flow.actAs(accounts.requester).start({ employeeId: 'e-2041' }); const dialog = await flow.waitFor(instance).toHaveDialog('approve request'); await expect( flow.actAs(accounts.approver).completeActivity(dialog, { approved: 'yes' }), ).rejects.toThrow('Cause code: WF_COMMAND_INPUT'); await flow.actAs(accounts.approver).completeActivity(dialog, { approved: true }); const completed = await flow.waitFor(instance).toBeCompleted(); expect(completed.data).toEqual({ employeeId: 'e-2041', approved: true });});The first test registers a fixture for Directory before it starts the
workflow. It then checks that the requester may not answer the dialog, that the
approver may, and that the workflow completes with the expected result. The
test’s lookup function is a Vitest mock (vi.fn) that counts its calls. It
must have been called exactly once, although the workflow ran again after the
answer: the second run read the recorded result. A type check alone cannot show
this.
The second test submits approved: 'yes', which does not match the schema.
The submission is refused with the cause code WF_COMMAND_INPUT, the dialog
keeps waiting, and the corrected answer completes the instance.
In the starter, run the type check and the Workflow Tests from the project directory:
npm run checknpm run test:workflow
Outside the starter, run the Workflow Test distribution directly; Test workflows explains the setup and the command.
A workflow validates two kinds of input. The schema in defineWorkflow
validates the start parameters, here { employeeId: string }. The schema on
flow.dialog validates the person’s answer, here { approved: boolean }.
TypeScript infers the types of flow.params.employeeId and answer.approved
from those schemas, so the editor knows them too.
Types help while you write code; schemas check data that arrives at run time.
Try adding a required reason: v.string() to the answer schema, then update the
test’s submitted answer and the expected result.
IdentityFlow checks an answer against the dialog’s schema when it is
submitted, custom checks included. An answer that does not match is refused and
nothing is recorded: GraphQL completeDialog returns BAD_USER_INPUT with each
rejected field in extensions.issues, and a Workflow Test’s completeActivity
is rejected with the cause code WF_COMMAND_INPUT. The dialog keeps waiting, so
a corrected answer completes it. The second test in
Verify the result does exactly that.
The workflow checks the recorded answer again when it continues. That check
can fail for an answer recorded without the check on submission: by
IdentityFlow 0.3.0 or earlier, or when IdentityFlow could not decide the check,
for example because the schema threw an error. It can also fail for an answer
that passed the check on submission, when the validation rules change before the
workflow continues, for example because a newer compatible version of the
workflow declares a stricter schema. Then await flow.dialog(…) throws a
ValidationError, which is not retried, and if the workflow does not catch it,
the instance ends as ERRORED.
For a start form with labels, choices and numeric limits, copy Build a start form from the input schema. Valibot can describe that form automatically; another Standard Schema validator can validate input without supplying a form description.
When an instance continues after a wait or a restart, IdentityFlow runs the workflow function again from the top. Steps that already finished return their recorded results; Concepts explains this in detail. From that follow the rules:
Code outside a step runs again when the workflow resumes. Keep it deterministic: read input, branch on recorded results and call named steps. Put clock reads, randomness, service calls and database access inside a step callback so the result can be recorded.
Use a stable, unique name for each logical step. In a loop, derive the name from stable input, such as an item ID. Give each external write an idempotency key understood by the receiving service: a process can stop after the service accepted the write but before IdentityFlow recorded the result. A retry must not create a duplicate business effect.
A deployed version is immutable. Increase version when changing behavior.
Running instances do not stay on the version they started with: each time an
instance continues, IdentityFlow runs it with the newest deployed version in
the semver range ^<its current version>. From 1.0.0 on, that is every later
minor and patch version of the same major version; below 1.0.0, the range is
narrower (0.2.0 is not in ^0.1.0). A minor or patch version is therefore
only safe if every running instance can continue on it:
- its params schema accepts every value the older versions accepted, and
schemas that decode already-recorded results still accept them. Parameters
are checked at every continuation. If an instance’s recorded parameters no
longer match the schema, the instance becomes
ERROREDbefore any step runs; - steps that already ran keep their names, their kinds and their order; new steps come only after them, or in branches older instances never reached. A step that replays with a different kind fails.
Any other change, such as renaming, removing or reordering steps, changing a step’s kind or tightening the params schema, needs a new major version.
Install and run IdentityFlow shows in a figure which version a running instance moves to.
Make every version change in place: edit the definition in its file and raise
version. Do not keep the old version in a second file or a second export.
The host deploys only the default export, and two files that declare the same
name conflict. Instances on the old major version keep running without the
old code in your project: the host stores every deployed version in its
database and loads an instance’s version from there each time it continues.
Never edit or delete an event to repair a run. See
Deploy a new version of a workflow and
Errors and retries.
| Task | Copy or look up |
|---|---|
| Retry a temporary service failure | Retry a step and stop on a final error |
| End an unanswered dialog at a deadline | Withdraw a dialog after a deadline |
| Call a service behind a typed binding | Bindings and connectors and Call a GraphQL service through a binding |
| Reuse work in a child workflow | Child workflow example |
| Poll an external job until it has a result | Requests to external systems; Workflow Tests cannot run flow.request |
| Change who may answer without rewriting a workflow | Map a login to principals |
| Write your own UI | Build your own UI and the GraphQL API, not the Workflow Test driver |
| Configure discovery, providers and startup | Configuration and Install and run IdentityFlow |
The rest of this page covers steps that run at the same time. Read it when several operations should proceed together or a deadline should cancel a pending dialog.
flow.start() starts a child workflow: a separate instance whose result becomes
the step’s result. Give every child step a stable name, for the same reason as
above.
When independent steps can run at the same time, use the standard JavaScript
Promise methods; there is no special API. A Promise is the value an async call
returns before its result exists, comparable to a Go channel that delivers one
value or one error. await waits for it.
In the rest of this page, a step Promise is the Promise returned directly by
flow.do(), flow.sleep(), flow.dialog(), flow.request() or flow.start().
IdentityFlow tracks step Promises. A Promise you build from one, for example
with .then(), is an ordinary Promise and is not tracked. The Promise method you
pick decides what happens to step Promises that are still running when the
combined Promise settles.
Promise.all, Promise.allSettled, Promise.any and Promise.race behave as
in standard JavaScript: same result order, same rejections. IdentityFlow adds
one thing: steps that a method cancels are recorded, so the cancellation
survives a restart.
Promise methods, cancellation and examples
A step Promise rejects when its step fails with an error. A step that is only waiting does not reject.
| Method | When the combined Promise settles | Step Promises still running |
|---|---|---|
Promise.all | every input fulfills, or the first input rejects | canceled after the first rejection |
Promise.allSettled | every input has fulfilled or rejected | never canceled |
Promise.any | the first input fulfills, or every input rejects | canceled after the first success |
Promise.race | the first input fulfills or rejects | canceled after that first result |
A step that is waiting, for a person or for a timer, has not 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.
Use Promise.all when later work needs every result. Result order follows input
order. A rejection cancels the other step Promises that are still running,
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 never appears among the settled results. When the run has to wait,
IdentityFlow stops the current execution of your function by throwing an
AbortError through it, similar to cancelling a Go context.Context; the
function runs again from the top when the instance continues. A broad catch
around the whole composition would swallow that error. If you catch errors,
test them with isAbortError(error) from @identity-flow/sdk and rethrow
those.
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 step Promises that are still running. If every input
rejects, you get the standard 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
persisted as CANCELED before firstDeadline is handed back to you.
Cancellation is cooperative for flow.do(), flow.request() and flow.start():
IdentityFlow aborts the signal passed to your callback and waits for the
callback to stop. Pass that signal on to the calls you make, as the example at
the top of this page does with lookup.
Cancellation cannot 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.
Only the Promise a step returns directly carries its cancellation information.
As soon as you wrap it with .then(), .catch(), .finally(), a Promise
subclass or a thenable of your own, the engine can no longer cancel the step.
Look up parameters, results and constraints in the Workflow API reference.