Workflows
Workflows let you write multi-step business logic as a plain async function — and have the platform make every step durable for you. A workflow can sleep for a week, wait for a webhook, retry a flaky API call, kick off a child workflow, and resume exactly where it left off if the server restarts in the middle.
Create a workflows/ folder (or a single workflows.ts at your project root), declare a workflow with defineWorkflow, and use the step.* API for anything you want to be durable.
// workflows/onboarding.ts
import { defineWorkflow } from '@maravilla-labs/platform/workflows';
export const onboarding = defineWorkflow(
{ id: 'onboarding', options: { retries: 5, timeoutSecs: 86400 } },
async (input: { userId: string; email: string }, step, ctx) => {
const user = await step.run('fetch-user', () =>
ctx.database.findOne('users', { _id: input.userId }),
);
await step.run('send-welcome', () =>
ctx.queue.send('emails', { to: input.email, template: 'welcome' }),
);
await step.sleep('grace-period', '24h');
const survey = await step.waitForEvent('survey-reply', {
type: 'survey.submitted',
match: { userId: input.userId },
timeout: '7d',
});
return { completed: true, surveyed: survey !== null };
},
);
Quick Start
1. Install the workflows builder. Workflows ship as part of @maravilla-labs/functions — the same dev dependency used for event handlers. Without it, your workflows/ folder builds silently as a no-op.
npm install --save-dev @maravilla-labs/functions
Check your build output. Run
npm run buildand look for a line like✅ Workflows build completed: 1 workflowin the output, and for aworkflowssection inbuild/manifest.json. If you don’t see either, the dev dependency is missing.
2. Create a workflows/ folder (next to package.json), or drop a single workflows.ts at the project root. Split across as many files as you like — every .ts/.js file inside is picked up automatically.
3. Export named workflows with defineWorkflow. The id inside the descriptor is the workflow’s stable identity — keep it stable across deploys.
4. Deploy. Workflows are discovered at build time and registered with the runtime. Start a run from anywhere using platform.workflows.start(), or let a trigger start them for you.
Starting a Workflow
Kick off a run from a request handler, event handler, or another workflow:
import { getPlatform } from '@maravilla-labs/platform';
const platform = getPlatform();
const handle = await platform.workflows.start('onboarding', {
userId: 'u_123',
email: 'jane@example.com',
});
handle.runId; // unique run ID
await handle.status(); // WorkflowRun | null
await handle.history(); // full step-by-step ledger
await handle.result(); // waits for terminal state, returns the output
await handle.cancel(); // best-effort cancellation
handle.result() resolves with the workflow’s return value when the run completes, and throws if it failed or was cancelled. For a fire-and-forget start, just don’t await result().
Need a handle to an existing run? platform.workflows.handle(runId) gives you one without starting anything.
The Handler Signature
Every workflow handler receives three arguments: the input you passed to start(), a step API for durable operations, and a ctx object that mirrors the one in event handlers.
async (event, step, ctx) => {
// ── Metadata ────────────────────────────────
ctx.runId; // this run's unique ID
ctx.workflowId; // the workflow's declared id
ctx.attempt; // which retry attempt this is (1 on first run)
// ── Platform services ──────────────────────
ctx.kv; // KV store
ctx.database; // document DB
ctx.storage; // object storage
ctx.queue; // durable queue producer
ctx.workflows; // start / signal other workflows
ctx.platform; // full platform object — escape hatch
}
The handler runs inside a replay model: every time the workflow resumes (after a sleep, an event, or a crash), the platform re-invokes the handler with the full history of completed steps. The step.* API either returns the persisted result of a past step or runs it for the first time — you never see the difference.
Keep the code outside
step.*calls deterministic. Anything with a side effect, a random value, or a network call should live insidestep.run(...). Plain variable assignments and branching on step output are fine.
The step API
step.run(name, fn)
Execute fn once and memoize its output under name. On replay, the stored value is returned synchronously — your fn doesn’t run again.
const charge = await step.run('charge-card', async () => {
return await stripe.charges.create({ amount: 2000, customer: input.customerId });
});
await step.run('record-charge', () =>
ctx.database.insertOne('charges', { id: charge.id, userId: input.userId }),
);
Step names must be unique within a run. If fn throws, the run is marked failed; if retries are configured, the whole workflow restarts from the first incomplete step on the next attempt.
step.sleep(name, duration) / step.sleepUntil(name, epochMs)
Durable sleep. The run is parked; the worker moves on. When the wake time arrives — even if the server was down when it was scheduled — the workflow resumes on the next line.
await step.sleep('grace-period', '24h');
await step.sleep('retry-delay', 30_000); // milliseconds
await step.sleepUntil('midnight-utc', Date.UTC(2026, 0, 1));
Durations accept a number of milliseconds or a shorthand string: "500ms", "30s", "5m", "1h", "7d".
step.waitForEvent(name, options)
Pause until a matching event is signaled. Returns the event payload, or null if the timeout elapses first.
const approval = await step.waitForEvent('manager-approval', {
type: 'expense.approved',
match: { expenseId: input.expenseId },
timeout: '3d',
});
if (approval === null) {
await step.run('escalate', () => notifyFinanceTeam(input.expenseId));
}
Signal waiting runs from anywhere using platform.workflows.sendEvent():
// From an HTTP handler, event handler, or another workflow
await platform.workflows.sendEvent('expense.approved', {
expenseId: 'exp_42',
approvedBy: 'mgr_7',
});
Matching rules: eventType must equal the filter’s type; every key in match must appear in the payload with an equal value. The number of runs resumed is returned.
step.invoke(name, workflowId, input?, options?)
Start a child workflow, wait for it to finish, and return its output. The child is recorded as a single step in the parent’s history, so it doesn’t re-spawn on replay.
const invoice = await step.invoke('generate-invoice', 'invoice-builder', {
orderId: input.orderId,
});
await step.run('email-invoice', () =>
ctx.queue.send('emails', { to: input.email, invoiceId: invoice.id }),
);
options.timeout bounds the wait (default: 1 hour). A child that ends failed or cancelled throws in the parent.
Triggers
Workflows can start automatically on a schedule or in response to a platform event. Add a trigger to the descriptor to opt in — omit it to require explicit platform.workflows.start() calls.
Schedule Trigger
Run a workflow on a cron schedule. Standard 6-field cron (sec min hour day month weekday).
export const nightlyReconcile = defineWorkflow(
{
id: 'nightly-reconcile',
trigger: { kind: 'schedule', cron: '0 0 3 * * *' }, // 3:00 every day
},
async (_input, step, ctx) => {
const orders = await step.run('fetch-pending', () =>
ctx.database.find('orders', { status: 'pending_reconcile' }),
);
for (const order of orders) {
await step.run(`reconcile-${order._id}`, () => reconcile(order));
}
},
);
Missed ticks (e.g. the server was down) catch up on the next tick cycle.
Event Trigger
Start a run for every matching event on the platform’s event stream. Useful for long-running reactions that you don’t want to run inline in an event handler.
export const onSignup = defineWorkflow(
{
id: 'onboarding-after-signup',
trigger: { kind: 'event', t: 'auth.user.registered' },
},
async (event, step, ctx) => {
await step.sleep('wait-before-nudge', '2h');
await step.run('send-tips', () =>
ctx.queue.send('emails', { to: event.data.email, template: 'tips' }),
);
},
);
The event argument is the matching event’s payload. Filter further with r (resource) and ns (namespace) if you need a narrower match.
Triggers vs. event handlers. An
onXhandler is the right place for quick, synchronous reactions. Reach for a workflow when the reaction needs to survive restarts, span multiple steps, or wait for something.
Starting a Workflow From an Event Handler
The event trigger matches on r / t / ns, which is deliberately coarse. When you need precise filtering — a KV key glob, a specific collection + operation, a document-shape predicate — do it in an event handler and hand off to a workflow with platform.workflows.start(). You get full filter expressiveness on the entry side and full durability on the work side.
KV change → workflow. Trigger a nudge workflow only when keys matching inv:* are written:
// events/invites.ts
import { onKvChange } from '@maravilla-labs/platform/events';
export const nudgeOnInvite = onKvChange(
{ namespace: 'invites', keyPattern: 'inv:*', op: 'put' },
async (event, ctx) => {
await ctx.platform.workflows.start('nudge-followup', {
inviteKey: event.key,
ts: event.ts,
});
},
);
Database change → workflow. Kick off onboarding the moment a new user document lands — filter by collection and operation, then pass the document into the workflow:
// events/users.ts
import { onDbChange } from '@maravilla-labs/platform/events';
export const onboardNewUser = onDbChange(
{ collection: 'users', op: 'insert' },
async (event, ctx) => {
if (!event.doc?.email) return;
await ctx.platform.workflows.start('onboarding', {
userId: event.id,
email: event.doc.email,
});
},
);
The event handler stays a thin dispatcher; the workflow owns the multi-step, durable part (welcome email, 24h grace period, survey wait, follow-up). If the handler runs twice for the same event, make the workflow idempotent by including a stable id in the input and checking it on the first step.
Options
defineWorkflow(
{
id: 'checkout',
options: {
retries: 3, // whole-run retry budget (default: 3)
timeoutSecs: 600, // overall wall-clock deadline
},
},
async (input, step, ctx) => { /* ... */ },
);
retries— how many times the whole run may restart when a step throws. Retries replay from the first incomplete step; completed steps are never re-executed.timeoutSecs— hard wall-clock deadline for the whole run. Exceeding it marks the runfailed.
A Fuller Example — Order Fulfillment
// workflows/fulfillment.ts
import { defineWorkflow } from '@maravilla-labs/platform/workflows';
interface FulfillmentInput {
orderId: string;
customerEmail: string;
}
export const fulfillOrder = defineWorkflow(
{ id: 'fulfill-order', options: { retries: 5, timeoutSecs: 7 * 24 * 3600 } },
async (input: FulfillmentInput, step, ctx) => {
const order = await step.run('load-order', () =>
ctx.database.findOne('orders', { _id: input.orderId }),
);
const charge = await step.run('charge-payment', async () => {
return await chargeCard(order.customerId, order.total);
});
const shipment = await step.invoke('arrange-shipment', 'shipping', {
orderId: input.orderId,
address: order.shippingAddress,
});
await step.run('mark-shipped', () =>
ctx.database.updateOne(
'orders',
{ _id: input.orderId },
{ $set: { status: 'shipped', trackingId: shipment.trackingId } },
),
);
await step.run('email-tracking', () =>
ctx.queue.send('emails', {
to: input.customerEmail,
template: 'shipped',
trackingId: shipment.trackingId,
}),
);
const delivered = await step.waitForEvent('delivery-confirmation', {
type: 'shipment.delivered',
match: { trackingId: shipment.trackingId },
timeout: '14d',
});
if (!delivered) {
await step.run('open-support-ticket', () =>
openTicket(order.customerId, `No delivery after 14d for ${input.orderId}`),
);
}
return { chargeId: charge.id, trackingId: shipment.trackingId };
},
);
Start it from a route handler:
const handle = await platform.workflows.start('fulfill-order', {
orderId: 'ord_42',
customerEmail: 'jane@example.com',
});
return Response.json({ runId: handle.runId });
The checkout endpoint returns immediately. The workflow charges the card, hands off to a child workflow, emails the customer, waits up to 14 days for the carrier event, and opens a support ticket if it times out — surviving any number of deploys along the way.
Best Practices
- Put side effects inside
step.run. Everything else (branches, loops, variable assignments) is safe at the top level. If you make a network call or read from a service outsidestep.run, it will run again on every replay. - Use stable step names and workflow IDs. The
idondefineWorkflowand thenameon eachstep.*call are the identity keys the platform uses to find prior work. Renaming either is equivalent to deleting the old and creating a new one. - Keep
step.runbodies short and idempotent where possible. A retried run replays from the first incomplete step; a completed step’s output is memoized. Ifrunis long, break it up so you don’t redo expensive work after a transient failure. - Pass data through steps, not through closures. Return values from
step.runand use them downstream. Values captured in closures only survive within a single invocation. - Reach for
step.waitForEventinstead of polling. A workflow parked onwaitForEventuses no resources. Polling withstep.sleepin a loop works but wastes ledger space. - Prefer
step.invokeover deep nesting. Large workflows become hard to reason about. Split them into a parent that orchestrates and children that do the work.
Next Steps
- Event Handlers — quick synchronous reactions to platform events
- KV Store, Database, Storage — services available on
ctx - Realtime Channels — pub/sub used by
step.waitForEventsignals