Durable workflows
UI checkpoints restore component machines. A workflow checkpoint restores application work: its input, completed step results and output. The operation journal bridges an interruption between a side effect finishing and the workflow checkpoint being saved.
This is an opt-in API. Existing arbitrary promises and Zag callbacks do not become durable automatically. Call a workflow from a loader, transformer or action when its work needs this contract. Precompiled HTML remains unchanged; workflow modules load only when imported by the application.
Author a compiled workflow
// profile.workflow.ts
import { defineWorkflow, workflowStep } from "fuzor/workflow";
import { loadProfile, saveProfile } from "./operations";
export default defineWorkflow(
{ id: "profile", version: "1" },
async (input: { id: string }) => {
const profile = await workflowStep("load", loadProfile, input);
const saved = await workflowStep("save", saveProfile, { profile });
return { profile, saved };
},
);
The Fuzor Vite plugin compiles this function into explicit steps. Standalone Vite projects can install workflowPlugin() from fuzor/workflow/compiler. The compiler never evaluates application source during transformation. Uncompiled async definitions throw instead of silently replaying the function.
The supported authoring subset is a module-level definition with one input parameter, named const result = await workflowStep(...) statements and one final return. Inputs and the return may use JSON expressions, the workflow input and earlier results. Calls, loops, assignments, arbitrary captures, destructured results and statement-level branches are rejected. Put that work inside an operation, where its replay semantics are explicit. Branches within an operation resume at the operation boundary; they are not separately checkpointed.
Keep step IDs stable. Bump the workflow version when its graph or meaning changes, and operation versions when their behavior changes. Persisted runs remain tied to their original versions; migration is an application decision. Compiled checkpoints also carry a syntax signature and step operation identities/versions. Changed program meaning is rejected even if a version bump was forgotten; whitespace-only edits keep the signature.
Operations and journals
import { defineOperation } from "fuzor/workflow";
import { Effect } from "fuzor";
export const loadProfile = defineOperation({
id: "load-profile", version: "1", kind: "read",
execute: (input: { id: string }, { signal }) =>
Effect.tryPromise({
try: () => fetch(`/api/profiles/${encodeURIComponent(input.id)}`, { signal })
.then(response => response.json()),
catch: error => error,
}),
});
An operation receives { key, signal, attempt }. Inputs and successful results must be finite, acyclic, plain JSON. Each serialized record/checkpoint is bounded to 2 MiB and each workflow to 256 steps. Files belong in the UI file transport or application storage; pass their stable references into workflows.
Available journals:
createMemoryOperationJournal()fromfuzor/workflow: one process lifetime, useful for tests and short-lived work.createSqliteOperationJournal(filename)fromfuzor/workflow/node: durable on one host, with atomic compare-and-set across SQLite connections/processes. Create its parent directory first and close it during application shutdown.openIndexedDbOperationJournal(name)fromfuzor/workflow/browser: an Effect yielding a browser journal, with atomic transactions across tabs. Storage denial is an error; it does not silently fall back to memory. Callclose()when finished.
A claimed operation has a renewable lease. Another executor waits while it is live, then can reclaim an expired claim with the same key. Revisions fence old completions. The default lease is 30 seconds; leaseMs may be configured from 60 ms to one hour. A crash can require waiting for lease expiry. Browser suspension can also let a lease expire, so writes still require destination idempotency.
Successful results remain in the journal. Repeated execution returns the stored result rather than calling the operation again. Failures release the claim for a later explicit retry. No automatic retry loop repeats a failed side effect. Retain records for the replay period you promise; deleting them ends that protection. Journals have no automatic pruning policy.
Pause and resume through Effect Atom
import { Effect } from "fuzor";
import { createWorkflowRun } from "fuzor/workflow";
import { openIndexedDbOperationJournal } from "fuzor/workflow/browser";
import profile from "./profile.workflow";
const journal = await Effect.runPromise(openIndexedDbOperationJournal());
const run = createWorkflowRun(profile, {
id: "profile-save-123", input: { id: "123" }, journal,
// Add checkpoint: JSON.parse(savedJson) when restoring this run.
onCheckpoint: checkpoint => {
localStorage.setItem("profile-workflow", JSON.stringify(checkpoint));
},
});
const result = await run.run();
// Or: await Effect.runPromise(run.runEffect());
run.checkpoint() returns validated JSON through Effect Atom serialization. await run.pause() aborts pending execution, waits for cleanup, persists a suspended checkpoint and returns it. Recreate the same definition/run/input with checkpoint to resume. Completed steps are skipped; an interrupted step consults its journal before executing again. run.dispose() aborts and releases the Atom registry but does not persist a new checkpoint. Attach a rejection handler to a running promise if pausing or disposing it. When work is active, await pause() before closing its journal.
onCheckpoint runs before the checkpoint is published. If persistence fails, execution stops. A result already committed to the journal is recovered on the next run. Use one live owner per workflow checkpoint lineage. Shared asynchronous checkpoint storage should compare revisions atomically to refuse older writes. Local storage is a small synchronous example, not a secret store or a cross-device journal.
Run IDs distinguish separate intentions. Reusing an ID intentionally resumes the old work; generate a new ID for a new save. Keys include workflow ID/version, run ID and step ID. Changed operation identity/version or input under an existing key fails explicitly.
For a Zag async-list, its props.load callback can call run.run(signal) with the loader’s abort signal, provided that workflow returns the expected { items, cursor } shape. The machine checkpoint preserves loading intent; the operation journal preserves completed work. Give a new filter/page/read intention its own run ID and JSON input, rather than reusing a completed run for new data.
Server-side deduplication
// Browser operations.ts
import { remoteOperation } from "fuzor/workflow/http";
export const saveProfile = remoteOperation<
{ profile: { id: string; name: string } }, { saved: boolean }
>({ id: "save-profile", version: "1", kind: "write" }, {
endpoint: "/api/operations",
});
Mount createOperationRequestHandler() from fuzor/workflow/http on your server. Give it a persistent journal, an allow-listed operation map, the endpoint path and an authorize(request) Effect returning a trusted user/tenant scope (or null). Authorization runs on every request, including cached replays. Keys are partitioned by that scope; explicit cross-origin browser requests are rejected. Apply your application's normal authentication and CSRF policy. The handler returns an Effect yielding a Response or null for another path. Authentication errors and operation failures do not expose private exception text.
The client sends the same operation key on replay. If the server committed a result but the browser lost the response, the server returns that stored result. A browser journal alone cannot provide this protection across devices or after its storage is cleared.
Writes must declare replay: "idempotent" on the server and enforce that contract at the side-effect destination using context.key. For a database write, atomically record the key/result in the same transaction as the mutation. For an external API, use its supported idempotency mechanism. The Fuzor journal cannot atomically commit a third-party side effect: a server crash after that effect but before its journal commit can otherwise repeat the effect. The declaration is an explicit application contract, not proof that an arbitrary write is safe.
Verified boundaries
Tests cover persistent SQLite replay, competing connections, lease renewal/reclaim, stale completion fencing, cancelled work, checkpoint persistence failures, authenticated server partitions and a lost committed response. Production Chromium tests compile workflows in both SPA and MPA builds, reload during an in-flight write, restore completed reads and confirm that the server write runs once.
A separate integration test pauses and restores a real Zag async-list using a durable workflow, retaining the completed step and ignoring the interrupted request’s late result.
These workflows checkpoint explicit boundaries. They do not serialize arbitrary JavaScript stacks, promise internals, OS file pickers, permission dialogs or live sockets. Reattach browser resources and preserve the surrounding workflow when a platform-controlled interaction must be requested again.