Durable Operations
Durable Operations
Multi-step operations that touch external systems (Stripe, S3, filesystem) need crash safety. If the server dies mid-operation, the work must resume on next boot rather than leaving data in a broken half-done state.
Warpkit's durable operation pattern backs each operation with a state machine in the shared Drizzle DB. Completed steps are timestamped; incomplete operations are picked up by a startup reconciler.
Pattern overview
Each durable operation has:
- A jobs table in
src/lib/db/schema.tswith per-step completion timestamps and a concurrency lease createJob(params), inserts the job row and optionally sets a blocking flag on the user (e.g.deletionPendingAt)runJob(jobId), acquires the lease, executes each step in order (skipping already-completed steps), releases the lease on every exit pathreconcilePending(), called at server boot fromserver/start.tsto resume any jobs that were in progress when the server last stopped
Reference implementations
Account deletion (src/lib/operations/account-deletion.server.ts) is the canonical job-table example.
Steps (each has a completion timestamp column):
- Cancel Stripe subscription
- Delete Stripe customer
- Delete user data files
- Delete user DB
- Delete user row
// Enqueue (from a mutation):
import { createDeletionJob } from '@/lib/operations/account-deletion.server';
const job = await createDeletionJob({
userId: user.id,
stripeSubscriptionId: sub?.stripeSubscriptionId ?? null,
stripeCustomerId: sub?.stripeCustomerId ?? null,
initiatedBy: 'user'
});
// Startup reconciler, wired directly in server/start.ts:
const { reconcilePendingDeletions } = await import(
'@/lib/operations/account-deletion.server'
);
await reconcilePendingDeletions();
Billing reconciliation (src/lib/operations/billing-reconciliation.server.ts) is a second, no-job-table variant: it has no per-step state machine, runs once at startup only (also wired directly in server/start.ts), fetches every subscription row, and syncs each against Stripe (authoritative). Use this shape when the operation is a single idempotent sync pass rather than a multi-step sequence that needs to resume mid-way.
Step execution rules
- Steps that succeed: mark their completion timestamp, clear
lastError - Steps that fail: persist
lastError, release lease immediately, returnfalse - There is no
failedAtcolumn , incomplete jobs are always retried on next boot - A failing step does not roll back previous steps (operations are designed to be idempotent)
Lease
leaseExpiresAt prevents two server instances from running the same job concurrently. Default TTL: 5 minutes. The reconciler skips jobs with an active (non-expired) lease.
Adding a new durable operation
-
Add a jobs table to
src/lib/db/schema.tswith:id,userId,createdAt,updatedAt,leaseExpiresAt- One nullable timestamp column per step (e.g.
stripeDeletedAt) lastError TEXT
-
Create
src/lib/operations/my-operation.tswithcreateJob,runJob,reconcilePending -
In
runJob: acquire lease at start, release in all exit paths (success, failure, throw), skip steps whose timestamp is already set -
In
reconcilePending: query for jobs where all step timestamps are null AND (lease is null OR lease expired) -
Wire
reconcilePending()intoserver/start.tsdirectly, calling it via a dynamicimport('@/lib/operations/my-operation.server')(fire-and-forget.then(), not awaited , see the account-deletion/billing-reconciliation calls already there for the pattern). Notsrc/start.ts: Vite's import-protection plugin statically flags.server.tsdynamic imports there regardless of anif (import.meta.env.SSR)guard, sincestart.tsis a shared entry point the client bundler also inspects.server/start.tsis a plain Bun script never inspected by the client bundler at all , see the comment insrc/start.tsfor the precedent (this used to be wired through aserver/plugins/Nitro plugin instead, but that mechanism never actually ran; seedocs/warpkit/features/jobs.md's "Why server/start.ts, not a Nitro plugin").
When to use this pattern
Use durable operations when:
- The operation has 2+ steps that each touch an external system
- A partial failure leaves the user in a broken state
- The operation must eventually complete even if the server restarts mid-way
For single-step fire-and-forget work, use the job queue instead.