Quickstart
Install the schema, run your first job, then kill the worker mid-job and watch it finish anyway.
In five minutes you will enqueue a job, run it, and then prove the interesting part: a job that survives its own worker being killed. Choose TypeScript, Python, or Go for the application code. Schema installation uses the Node.js 22+ Workhorse CLI. Every path also needs a PostgreSQL connection string.
1. Install
npm install @stablemates/[email protected]2. Install the schema
Workhorse lives entirely inside your database: tables for jobs and versioned SQL functions for every lifecycle transition. Install them once through the TypeScript package's deployment CLI, regardless of which application SDK you use.
npx --package @stablemates/[email protected] workhorse schema installTypeScript deployment code can call the same installer directly.
import { installSchema, Pool } from "@stablemates/workhorse";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await installSchema(pool);Run this once, as a deployment step. Runtime code should call assertSchemaCompatible instead of
installing anything.
3. Enqueue a job and run it
A Queue accepts work; a Worker with a matching handler runs it. This example keeps both in one
file so you can watch the whole lifecycle.
import { Admin, Pool, Queue, Worker } from "@stablemates/workhorse";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const queue = new Queue(pool);
const admin = new Admin(pool);
const worker = new Worker(queue).handle("email.welcome", async (payload: { to: string }) => ({
deliveredTo: payload.to,
}));
const jobId = await queue.enqueue("email.welcome", { to: "[email protected]" });
await worker.runOnce(); // one claim-and-run pass; production uses worker.run()
console.log(await admin.getJob(jobId));
await pool.end();Each example reads the durable outcome after the worker records state succeeded. That record is
queryable evidence, not a log line — it stays after the process exits. The Python and Go examples
read the shared projection directly to keep the quickstart to one dependency; both languages also
ship Admin clients for the same operator reads.
4. Kill the worker. The job finishes anyway.
Now the part that makes Workhorse worth installing. This handler does two stages of work, each
wrapped in a named checkpoint, with a deliberate crash between them.
This crash walkthrough uses TypeScript so the two shell commands stay concrete. Python exposes the
same boundary as context.checkpoint, and Go exposes it as handler.Checkpoint.
The language clients page links their complete runnable lifecycle examples.
import { Pool, Queue, Worker } from "@stablemates/workhorse";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const queue = new Queue(pool);
const worker = new Worker(queue, { leaseMs: 5_000 }).handle(
"order.fulfill",
async (payload: { orderId: string }, ctx) => {
const charge = await ctx.checkpoint("charge", async () => {
console.log("charging card…"); // watch how many times this prints
return { chargeId: `ch_${payload.orderId}` };
});
if (!process.env.SURVIVED) {
console.log("simulating a crash");
process.exit(1); // the worker dies mid-job
}
const label = await ctx.checkpoint("label", async () => {
console.log("printing shipping label…");
return { labelId: `lb_${payload.orderId}` };
});
return { chargeId: charge.chargeId, labelId: label.labelId };
},
);
// The idempotency key makes the second run return the same job
// instead of enqueueing a new one.
await queue.enqueue(
"order.fulfill",
{ orderId: "42" },
{ maxAttempts: 5, idempotency: { key: "order:42" } },
);
await worker.run();Run it twice:
node crash-demo.ts # charges the card, then dies
SURVIVED=1 node crash-demo.ts # finishes the jobThe first run prints charging card… and exits. Nothing is lost: the checkpoint committed, the
lease expires, and the job becomes claimable again.
The second run picks the job up once the expired lease is recovered — within a few seconds — and
this time charging card… does not print. The completed
checkpoint replays its stored result instead of running again — the customer was charged exactly
once across the crash. That is the whole model: handlers restart from the top after any
interruption, and the boundaries you name are the parts that never repeat.
5. Enqueue with your data
In a real application, enqueue rarely stands alone. Pass your open transaction as the last argument and the job commits — or rolls back — with your business write.
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("INSERT INTO orders (id) VALUES ($1)", [orderId]);
await queue.enqueue("order.fulfill", { orderId }, {}, client);
await client.query("COMMIT");
} finally {
client.release();
}Next
- Core concepts — states, ownership, and why a handler can run twice
- Enqueue and transactions — everything an enqueue can carry
- Durable execution — checkpoints, durable sleeps, and their rules
Exact enqueue, ownership, and completion semantics: architecture reference.