Stores
The WorkflowStore contract and the built-in adapters
Stores
Durability is delegated to a small WorkflowStore contract; the engine stays storage-agnostic. Five stores ship in the
box (memory, unstorage, SQL, Redis, Durable Object), and you can implement the contract over anything else.
Built-in stores
MemoryStore (default)
In-process, ideal for tests and single-instance apps. Used automatically when you don't pass a store.
import { createRuntime, MemoryStore } from "@visulima/workflow";
const runtime = createRuntime({ store: new MemoryStore() });UnstorageStore
Durable, edge-friendly persistence over any unstorage driver (Cloudflare KV, D1, Redis, filesystem, memory…).
import { createRuntime, UnstorageStore } from "@visulima/workflow";
import { createStorage } from "unstorage";
import cloudflareKVBindingDriver from "unstorage/drivers/cloudflare-kv-binding";
const runtime = createRuntime({
store: new UnstorageStore(createStorage({ driver: cloudflareKVBindingDriver({ binding: env.RUNS }) })),
});A small due-index document tracks wake-at times so sweep does not scan every run. Operations are not transactional; its
optional lease is best-effort on non-atomic drivers (see Concurrency).
SqlStore (PostgreSQL / MySQL)
Durable persistence in a SQL database, with a genuinely-atomic cross-process lease (a single conditional UPDATE),
so it is safe for many runtime instances against one database. Driver-agnostic: adapt any client (node-postgres,
postgres.js, mysql2, pglite…) to the structural SqlClient — a single query(sql, params) => { rows, rowCount }.
import { createRuntime, SqlStore } from "@visulima/workflow";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const store = new SqlStore(
{ query: (sql, parameters) => pool.query(sql, parameters as unknown[]) }, // pg returns { rows, rowCount }
{ dialect: "postgres" },
);
await store.init(); // create the table (also runs lazily on first use)
const runtime = createRuntime({ store });For MySQL pass { dialect: "mysql" } and connect with the FOUND_ROWS client flag (so the lease's affected-rows check
counts matched, not changed, rows). Run state is stored as JSON text, so no JSON column type is required.
RedisStore
Durable persistence in Redis, with an atomic Lua acquire/release lease and a sorted-set wake index — safe across
instances. ioredis satisfies the structural RedisLike directly.
import { createRuntime, RedisStore } from "@visulima/workflow";
import Redis from "ioredis";
const store = new RedisStore(new Redis(process.env.REDIS_URL));
const runtime = createRuntime({ store });DurableObjectStore (Cloudflare)
Persistence in a Cloudflare Durable Object's storage. Because a
Durable Object processes requests serially, the lease is race-free by construction — the cleanest correct
cross-process store. It also uses DO alarms: the store arms an alarm at the earliest wake-at, so the object wakes
itself to sweep — no external cron needed (the push model the due contract was designed for). The adapter takes a
structural storage object, so it needs no @cloudflare/workers-types dependency.
import { createRuntime } from "@visulima/workflow";
import { DurableObjectStore } from "@visulima/workflow/store/durable-object";
export class WorkflowDurableObject {
#runtime;
constructor(state: DurableObjectState) {
this.#runtime = createRuntime({ store: new DurableObjectStore(state.storage), workflows });
}
async fetch(request: Request) {
// route trigger() / signal() calls into this.#runtime
}
async alarm() {
await this.#runtime.sweep(); // the store re-arms the next alarm after each save
}
}One Durable Object instance is one runtime, so there is no cross-instance contention to coordinate — the DO is the
single owner. Completed runs are retained in storage (for getRun); delete(runId) to evict them.
The contract
interface WorkflowStore {
save(run: StoredRun): Promise<void>;
load(runId: string): Promise<StoredRun | undefined>;
delete(runId: string): Promise<void>;
due(now: number, limit: number): Promise<string[]>; // runs whose wakeAt has passed
// Optional cross-process lease (omit to rely on in-process locking only):
acquire?(runId: string, token: string, ttlMs: number): Promise<boolean>;
release?(runId: string, token: string): Promise<void>;
}due(now, limit)is the poll thesweepentrypoint uses. The contract is deliberately poll-based so it works on plain KV/SQL, but it is shaped so a push-based adapter (e.g. Durable Object alarms) can implementdueas a no-op and schedule wake-ups insidesave.acquire/releaseare the optional cross-process lease. Implement them with an atomic primitive (RedisSET NX, a SQL row lock, a Durable Object) for race-free exclusion across instances. See Concurrency & exactly-once.
Writing your own
A minimal store needs only the four required methods. StoredRun carries the JSON-serialisable snapshot plus
denormalised status, wakeAt and eventName so you can implement due and signal-routing without parsing the
snapshot. Keep save ordering-safe: if your backend isn't transactional, persist the wake index before the run so a
crash yields at worst a harmless spurious wake (which resume re-validates and ignores) rather than a lost wake-up.