Workhorse
Getting started

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 install

TypeScript 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.

crash-demo.ts
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 job

The 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


Exact enqueue, ownership, and completion semantics: architecture reference.