Cloudflare Workers Quick Start
Sayiir runs natively inside Cloudflare Workers. A Rust/WASM core handles orchestration and checkpointing; persistence lives in D1 — SQLite at the edge. No external database, no separate orchestrator.
Installation
Section titled “Installation”pnpm add @sayiir/cloudflareD1 binding
Section titled “D1 binding”Add a D1 database to wrangler.toml:
[[d1_databases]]binding = "DB"database_name = "sayiir"database_id = "<your-database-id>"interface Env { DB: D1Database;}Simple workflow
Section titled “Simple workflow”import { task, flow, Engine } from "@sayiir/cloudflare";
const fetchUser = task("fetch-user", async (id: number) => { const res = await fetch(`https://api.example.com/users/${id}`); return res.json() as Promise<{ id: number; name: string }>;});
const sendEmail = task("send-email", async (user: { id: number; name: string }) => { return `Sent welcome to ${user.name}`;});
const onboarding = flow<number>("onboarding") .then(fetchUser) .then(sendEmail) .build();
export default { async fetch(request: Request, env: Env): Promise<Response> { const engine = await Engine.create(env.DB); const status = await engine.run(onboarding, "onboard-42", 42); return Response.json(status); },};Sayiir checkpoints to D1 after every task. Completed tasks are never re-executed — the task in-flight at eviction is the only one that re-runs on resume.
Parking — delays and signals
Section titled “Parking — delays and signals”Workers are short-lived. When a workflow hits a delay() or waitForSignal() it parks: the engine writes a snapshot to D1, returns a non-completed status with wake-up metadata, and your Worker returns a response. A later trigger (cron, queue, HTTP) resumes execution.
const orderApproval = flow<string>("order-approval") .then(submitOrder) .delay("cooling-period", "1h") .waitForSignal("approval", "manager_approval", { timeout: "48h" }) .then(notifyCustomer) .build();
export default { async fetch(request: Request, env: Env): Promise<Response> { const engine = await Engine.create(env.DB); const instanceId = new URL(request.url).pathname.split("/").pop()!; const status = await engine.run(orderApproval, instanceId, instanceId); return Response.json(status, { status: status.status === "completed" ? 200 : 202 }); },
async scheduled(event: ScheduledEvent, env: Env): Promise<void> { const engine = await Engine.create(env.DB); await engine.resumeAll(orderApproval); },};Signals — external events
Section titled “Signals — external events”const engine = await Engine.create(env.DB);await engine.sendSignal(instanceId, "manager_approval", { approvedBy: "alice" });The next engine.resume() picks up the buffered signal and continues. For lower latency than the cron interval, call engine.resume() inline from the signal handler.
Recovering from eviction
Section titled “Recovering from eviction”engine.resumeAll() in a scheduled handler sweeps three categories of instances in one pass:
- Parked instances whose delay or signal timeout has elapsed.
- Instances waiting on a signal that has already been delivered.
- Actively-running instances with no update within
staleAfterseconds (default 300) — the Worker eviction recovery path.
async scheduled(event: ScheduledEvent, env: Env): Promise<void> { const engine = await Engine.create(env.DB); await engine.resumeAll(orderApproval, { staleAfter: 60, limit: 20 });}The stale path explicitly excludes parked positions — a workflow correctly waiting on an external signal won’t get re-resumed every staleAfter window just because its snapshot is old. Only a buffered event or an explicit resume() wakes it.
Conflict policy
Section titled “Conflict policy”engine.run(workflow, instanceId, input) is not an idempotent alias for resume(). By default it throws when an instance with the same id already exists — the correct action for a known instance is resume().
const engine = await Engine.create(env.DB); // fail (default)const engine = await Engine.create(env.DB, { conflictPolicy: "use_existing" }); // idempotent retriesconst engine = await Engine.create(env.DB, { conflictPolicy: "terminate_existing" }); // force fresh startA definition-hash mismatch against an existing snapshot is always a hard error — you can’t restart a different workflow under the same id.
Fork / join, loops, routing
Section titled “Fork / join, loops, routing”The full flow builder works on Workers:
const checkout = flow<{ id: number }>("checkout") .fork([ branch("inventory", checkInventory), branch("payment", validatePayment), ]) .join("finalize", ([inventory, payment]) => ({ ...inventory, ...payment })) .build();Branches run sequentially inside a single Worker invocation (single-threaded), but each branch checkpoints independently — only uncompleted branches re-execute on resume.
D1 snapshot size limit
Section titled “D1 snapshot size limit”Sayiir checkpoints each task’s return value as a JSON row in D1, and D1 imposes a ~1MB row size limit. Most workflow values are well under this, but a few patterns can push intermediate state past the limit:
- chunked documents (full text + per-chunk text + overlap)
- batched embeddings (hundreds of 768-dim float vectors per task)
- bulk API responses passed forward intact
A string or blob too big: SQLITE_TOOBIG error from a task return is the symptom. The fix is to shrink the value that crosses the checkpoint, not the work itself. Two common patterns:
- Pass an ID, re-read on the other side. Persist heavy state to the bindings you already have (D1 tables, R2, Vectorize) inside the task, then return only the identifier. Downstream tasks re-read what they need.
- Coalesce tasks. Combine the two adjacent tasks that share heavy state into one task so the data never crosses a checkpoint. Trade-off: a Worker eviction inside the combined task loses both stages’ work.
@sayiir/cloudflare already supports binary types (ArrayBuffer, Uint8Array) across task boundaries via a base64-tagged JSON envelope (~1.33× overhead), but the size budget still applies — for anything past tens of KB, storing the raw bytes in R2 and passing the key is usually better than embedding them in workflow state.
Next steps
Section titled “Next steps”- Node.js API Reference — Same surface as
@sayiir/cloudflareminus backend specifics - Durable Workflows — Lifecycle operations
- Signals & Events — Wait for external events
- Parallel Workflows — Fork/join parallelism