The simplest Postgres workflow engine for TypeScript. Durable execution, event-driven orchestration, and automatic retries - powered entirely by PostgreSQL. No Redis, no message broker, no new infrastructure.
npm install pg-workflows pg
import { WorkflowEngine, workflow } from 'pg-workflows'
import { z } from 'zod'
const onboardUser = workflow(
'onboard-user',
async ({ step, input }) => {
const user = await step.run('create-account', () => db.users.create(input))
await step.run('send-welcome', () => sendEmail(user.email, 'Welcome!'))
return { userId: user.id }
},
{ inputSchema: z.object({ email: z.string().email() }) },
)
const engine = new WorkflowEngine({
connectionString: process.env.DATABASE_URL,
workflows: [onboardUser],
})
await engine.start()
await engine.startWorkflow({
workflowId: 'onboard-user',
input: { email: 'alice@example.com' },
})
That's it. Each step runs exactly once. Crash, redeploy, or retry - the workflow resumes from where it left off. State lives in your existing PostgreSQL database.
step.waitFor('event-name') pauses the workflow until your API fires an event. Zero resources consumed while waiting.step.delay('3 days'), step.waitUntil('2025-01-01'), step.poll(...) - no cron, no external scheduler.idempotencyKey and duplicate calls safely return the same run.npm install pg-workflows pg
pgis a peer dependency.pg-bossis bundled - nothing else to configure. The engine runs migrations automatically on start.
import { workflow } from 'pg-workflows'
import { z } from 'zod'
export const sendWelcome = workflow(
'send-welcome',
async ({ step, input }) => {
const user = await step.run('create-user', async () => {
return { id: '123', email: input.email }
})
await step.run('send-email', async () => {
await sendEmail(user.email, 'Welcome!')
})
// Pause until your API confirms the user. Zero cost while waiting.
const confirmation = await step.waitFor('await-confirmation', {
eventName: 'user-confirmed',
timeout: 24 * 60 * 60 * 1000, // 24 hours
})
return { success: true, user, confirmation }
},
{
inputSchema: z.object({ email: z.string().email() }),
retries: 3,
},
)
import { WorkflowEngine } from 'pg-workflows'
import { sendWelcome } from './workflows'
const engine = new WorkflowEngine({
connectionString: process.env.DATABASE_URL,
workflows: [sendWelcome],
})
await engine.start()
const run = await engine.startWorkflow({
workflowId: 'send-welcome',
input: { email: 'user@example.com' },
})
// Later - resume the workflow with an event:
await engine.triggerEvent({
runId: run.id,
eventName: 'user-confirmed',
data: { confirmedAt: new Date() },
})
// Track progress anytime:
const progress = await engine.checkProgress({ runId: run.id })
console.log(`${progress.completionPercentage}% complete`)
That's the whole loop. No extra services. Everything durable. Everything queryable with plain SQL.
See runnable examples and common patterns →
WorkflowEngine, WorkflowClient, WorkflowRef, typesotelPluginpg-workflows ships a first-party plugin that emits OTel spans for workflow and step execution. @opentelemetry/api is an optional peer dependency — install it only if you want tracing.
npm install @opentelemetry/api @opentelemetry/sdk-node
import { NodeSDK } from '@opentelemetry/sdk-node'
import { trace } from '@opentelemetry/api'
import { workflow, otelPlugin } from 'pg-workflows'
// Initialize your OTel SDK however you normally do — for Node apps the
// NodeSDK registers an AsyncHooks context manager, which is required for
// hierarchical (parent/child) spans across async boundaries.
new NodeSDK({ /* exporters, resource, ... */ }).start()
const tracedWorkflow = workflow.use(otelPlugin())
const myWorkflow = tracedWorkflow('checkout', async ({ step }) => {
await step.run('charge', async () => { /* ... */ })
await step.waitFor('await-shipment', { eventName: 'shipped' })
})
The plugin emits a pg_workflows.workflow.run span per worker execution (one per resume cycle), with child spans per step kind (pg_workflows.step.run, pg_workflows.step.waitFor, etc.). Spans carry workflow.id, workflow.run_id, workflow.attempt and, where set, workflow.resource_id. Steps replayed from cache after a pause emit no spans.
Options:
otelPlugin({
tracer: trace.getTracer('my-app'), // default: trace.getTracer('pg-workflows')
spanNamePrefix: 'pg_workflows', // default shown
attributes: (ctx) => ({ tenant: ctx.resourceId }), // extra static attrs on workflow.run
})
Metrics, distributed trace context propagation across child workflows, and HTTP-caller context propagation are not in v1 — see the observability docs for the deferral rationale.
Special thanks to the teams behind Temporal, Inngest, Trigger.dev, and DBOS for pioneering durable execution patterns and inspiring this project.
MIT
$ claude mcp add pg-workflows \
-- python -m otcore.mcp_server <graph>