maravilla-workflows
Maravilla Workflows
Workflows are durable, replay-based functions. They run inside the runtime, persist every step's output to a ledger, and survive process restarts: on resume, the runtime replays the workflow function up to the last completed step, then continues from there.
This makes them ideal for:
- Multi-step processes where each step depends on the previous (
step.runfor at-most-once side effects) - Long sleeps (
step.sleep('1h'),step.sleepUntil(date)) — the function isn't running while it sleeps - Waiting for an external event (
step.waitForEvent) - Composition (
step.invoketo call a child workflow)
Workflows are unconditionally enabled — there's no opt-in flag. They have no HTTP trigger; you start them from your runtime code via platform.workflows.start(...).
Defining a workflow
Workflow files live in workflows/*.ts (auto-discovered by the framework adapter at build time). Use defineWorkflow from the runtime subpath:
import { defineWorkflow } from '@maravilla-labs/functions/workflows/runtime';
interface Input { userId: string; reportId: string; }
export const buildReport = defineWorkflow<Input>(
{ id: 'build-report', options: { retries: 3, timeoutSecs: 60 * 60 } },
async (input, step, ctx) => {
const data = await step.run('fetch-data', async () => {
return await fetchExpensiveData(input.userId);
});
const rendered = await step.run('render', async () => {
return await renderPdf(data);
});
await step.run('upload', async () => {
await ctx.platform.env.STORAGE.put(`reports/${input.reportId}.pdf`, rendered);
});
return { ok: true };
},
);
Import note for v0.2.5:
defineWorkflowis exposed at@maravilla-labs/functions/workflows/runtime. The docs'platform/workflowssubpath does not resolve in this release.
Canonical example — the "click-watch" pattern
This is the demo's per-invitee click-watch workflow, lifted verbatim. One run per invitee escalates an unread-link warning twice over short windows, exits cleanly if the invitee record disappears, and emits live status via KV writes (which fire REN events for the owner's UI).
/**
* One durable workflow per invitee.
*
* t=0 start (snapshot + go to sleep)
* t=first-grace check 1 — if not clicked, flag `unclicked_first` (amber chip)
* t=first-grace + second check 2 — if still not clicked, flag `unclicked_final` (red chip)
*
* Each `ctx.kv.put` on `inv:{nanoid}` fires a REN event the owner's guest
* list is already subscribed to, so chips appear live with no reload.
*/
import { defineWorkflow } from '@maravilla-labs/functions/workflows/runtime';
interface Input { inviteeNanoid: string; inviteId: string; ownerUserId: string; }
export const inviteeClickWatch = defineWorkflow<Input>(
{ id: 'invitee-click-watch', options: { retries: 3, timeoutSecs: 7 * 24 * 3600 } },
async (input, step, ctx) => {
const kv = ctx.kv as { get: any; put: any };
const key = `inv:${input.inviteeNanoid}`;
await step.sleep('first-grace', '60s');
const firstOutcome = await step.run('check-1', async () => {
const raw = await kv.get('invites', key);
if (!raw) return 'removed' as const;
const invitee = JSON.parse(raw);
if (invitee.clicked_at) return 'clicked' as const;
invitee.unclicked_first = true;
await kv.put('invites', key, JSON.stringify(invitee));
return 'unclicked' as const;
});
if (firstOutcome === 'removed') return { outcome: 'invitee-removed' };
await step.sleep('second-grace', '120s');
const secondOutcome = await step.run('check-2', async () => {
const raw = await kv.get('invites', key);
if (!raw) return 'removed' as const;
const invitee = JSON.parse(raw);
if (invitee.clicked_at) return 'clicked' as const;
invitee.unclicked_final = true;
await kv.put('invites', key, JSON.stringify(invitee));
return 'unclicked' as const;
});
if (secondOutcome === 'removed') return { outcome: 'invitee-removed' };
return { outcome: 'done', firstOutcome, secondOutcome };
},
);
Two patterns to copy:
- Wrap every side effect in
step.run. Nakedawait fetch(...)orawait kv.put(...)re-runs on replay.step.run('name', ...)is recorded in the ledger and skipped on replay if already completed. - Exit cleanly on missing data. Returning early when the watched record is gone makes the workflow safe against deletions; you don't need to remember to cancel each run.
The step API
step.run(name, fn) — at-most-once side effect
const result = await step.run('charge-card', async () => {
return await stripe.charges.create({ amount, source });
});
The first time this step runs, fn executes and its return value is persisted. On replay, the persisted value is returned without re-running fn. Each step name within a workflow run must be unique.
step.sleep(name, duration) — short-form sleep
await step.sleep('cool-down', '30s');
await step.sleep('grace', '24h');
Duration formats: <n>s, <n>m, <n>h, <n>d. The function unwinds while sleeping — your isolate isn't pinned for hours.
step.sleepUntil(name, date) — sleep to a wall-clock target
await step.sleepUntil('event-start', new Date(invite.event_date));
Pass a Date or ISO-8601 string.
step.waitForEvent(name, filter, options?) — durable rendezvous
const payment = await step.waitForEvent('payment-received', {
type: 'payment.completed',
match: { orderId: input.orderId }, // every key must equal in payload
}, { timeoutMs: 60 * 60 * 1000 });
if (!payment) {
return { outcome: 'payment-timeout' };
}
Resolves when something else calls platform.workflows.sendEvent('payment.completed', { orderId, ... }) and the match keys equal the payload's. Returns the payload, or null on timeout.
step.invoke(name, workflowId, input) — child workflow
const handle = await step.invoke('child', 'send-receipt', { orderId, email });
const result = await handle.result();
Composes workflows. The child's full step history is its own; the parent records only the invocation result.
Starting and managing runs
Run starts come from your normal runtime code (route handlers, event handlers, other workflows) — there is no HTTP trigger for workflows.
const platform = getPlatform();
// Start
const handle = await platform.workflows.start('build-report', { userId, reportId });
console.log(handle.runId);
// Get status (poll or surface in admin UI)
const run = await handle.status();
// { runId, workflowId, status: 'queued' | 'running' | 'sleeping' | 'waiting_event' | 'completed' | 'failed' | 'cancelled', ... }
// Step history (debugging)
const steps = await handle.history();
// Wait for completion
const output = await handle.result({ timeoutMs: 5 * 60_000 });
// Cancel
const cancelled = await handle.cancel(); // best-effort; returns true if it transitioned
// Get a handle to an existing run (no start)
const existing = platform.workflows.handle(savedRunId);
Sending events to waiters
// Anywhere in your runtime code:
const woken = await platform.workflows.sendEvent('payment.completed', {
orderId: '123',
amount: 4200,
});
// woken: number of runs resolved
Match rules: the eventType you pass must equal the waiter's filter type; every key in the waiter's match must be present in payload with an equal value.
Patterns
Lazy-start on first sight
If you can't always reach a "creation" point — e.g. the invitee row may already exist — start the workflow lazily on any save and let the workflow itself short-circuit if it's a duplicate:
const handle = await platform.workflows.start('invitee-click-watch', {
inviteeNanoid, inviteId, ownerUserId,
});
// Multiple starts → multiple runs; design the workflow to be safe on duplicates
// (e.g. include the nanoid in step names, or check a marker before flagging)
For strict deduplication, lookup by a stable key in KV:
const existing = await kv.get('workflow-runs', `click-watch:${inviteeNanoid}`);
if (!existing) {
const handle = await platform.workflows.start(/* ... */);
await kv.put('workflow-runs', `click-watch:${inviteeNanoid}`, handle.runId);
}
Saga / compensation
Each side effect lives in its own step.run. On failure, run compensating steps:
try {
const charge = await step.run('charge', () => stripe.charges.create(/* ... */));
await step.run('reserve-inventory', () => inventory.reserve(items));
await step.run('ship', () => shipping.create(/* ... */));
} catch (err) {
await step.run('refund', () => stripe.refunds.create({ charge: charge.id }));
throw err;
}
Reminder pipeline
await step.sleep('1h-warning', '1h');
await step.run('send-1h-warning', () => platform.push.send(target, /* ... */));
await step.sleep('5m-warning', '55m');
await step.run('send-5m-warning', () => platform.push.send(target, /* ... */));
await step.sleepUntil('event-time', input.event_date);
await step.run('event-started', () => /* ... */);
Pitfalls
- Naked side effects in the workflow body. Anything outside
step.runre-runs on every replay. Evenconsole.logis fine, butfetch,kv.put,db.insertOneare not — wrap them. - Non-deterministic logic outside steps.
Math.random(),Date.now(),crypto.randomUUID()outsidestep.runwill give different values on replay. Capture them inside a step. - Step name collisions. The runtime keys steps by
nameper-run. Reusing a name in the same run is undefined behavior — append an iteration counter if you loop. - Long timeouts.
options.timeoutSecsis the whole-run budget. Make sure it covers worst-case sleeps + step durations. - Workflow vs event. If you only need to react once and quickly, use an event handler (see maravilla-events). Workflows pay a ledger cost per step.
Related skills
- maravilla-events — single-trigger handlers; often the entry point that starts a workflow
- maravilla-realtime —
step.waitForEventrendezvous source - maravilla-push — typical workflow side effect (reminders)
- maravilla-kv — surfacing live workflow state to the UI
Full reference: https://www.maravilla.cloud/llms-full.txt.
More from maravilla-labs/maravilla-cli
maravilla-auth
Maravilla Cloud authentication. Use whenever wiring login/register/session, OAuth callbacks, resource policies, or hitting `platform.auth.*` APIs. Critical: the 3-step request-scoped contract (validate → setCurrentUser → can) — skipping any step silently breaks Layer-2 policies and owner-scoped reads return empty with no error.
12maravilla-events
Maravilla Cloud event handlers — files in `events/*.ts` auto-discovered by the framework adapter. Use to react to data changes (`onKvChange`, `onDb`), auth lifecycle (`onAuth`), schedule (`onSchedule`), queue messages (`onQueue`), realtime publishes (`onChannel`), deploy phases (`onDeploy`), object storage (`onStorage`), or arbitrary REN events (`defineEvent`). Run inside the Maravilla runtime with full platform access via `ctx`.
12maravilla-media-transforms
Async media + document derivations via `platform.media.transforms` and the declarative `transforms` block in `maravilla.config.ts`. Media: transcode video, thumbnail extraction, image resize/variants, OCR. Documents (.docx/.odt/.pptx/.xlsx/...): convert to PDF, render page thumbnails, generic format conversion, Markdown extraction (RAG-ready), single-file HTML with inlined images, image-replacement templating ({{TAG}} swap + named-object swap), QR-code injection. Use when ingesting user uploads that need normalised renditions, generating contracts/invoices from templates, or extracting structured content for LLMs. Critical: derived keys are content-addressed — `keyFor(srcKey, spec)` is known up front, before the worker starts, so clients can render placeholder UI without round-trips. Declarative config is the default; imperative `transforms.*` calls are for one-offs.
12maravilla-db
Maravilla Cloud document database — MongoDB-style queries, secondary indexes, and vector search. Use for structured app data, multi-field queries, sorting, semantic search via `findSimilar` / hybrid `find` with `options.vector`. Exposed as `platform.env.DB`. Vector indexes support int8/bit quantization, matryoshka, and multi-vector (ColBERT) out of the box.
11maravilla-config
The `maravilla.config.ts` declarative project file. Use whenever creating or modifying auth resources, groups, relations, registration fields, OAuth providers, password/session policy, branding, database indexes, or media transforms. Reconciled into delivery on every deploy — partial adoption is supported (omit a section to leave it untouched).
11maravilla-push
Maravilla Cloud Web Push — server `platform.push.send/schedule/cancelScheduled/listScheduled` with idempotent keys and recurring `everySeconds`, browser `registerPush({ topics, userId, swPath })` from `@maravilla-labs/platform/push`. Use for browser notifications, scheduled reminders, recurring digests, and per-user fan-out by topic.
11