Run durable workflows on Postgres. No servers, no queues, just your database.
npm install @hotmeshio/hotmesh
Install the package:
npm install @hotmeshio/hotmesh
The repo includes a docker-compose.yml that starts Postgres, NATS, and a development container:
docker compose up -d
Then follow the Quick Start guide for a progressive walkthrough — from a single trigger to conditional, parallel, and compositional workflows.
Both approaches reuse your activity functions:
// activities.ts (shared between both approaches)
export async function checkInventory(itemId: string): Promise<number> {
return getInventoryCount(itemId);
}
export async function reserveItem(itemId: string, quantity: number): Promise<string> {
return createReservation(itemId, quantity);
}
export async function notifyBackorder(itemId: string): Promise<void> {
await sendBackorderEmail(itemId);
}
// workflows.ts
import { Durable } from '@hotmeshio/hotmesh';
import * as activities from './activities';
export async function orderWorkflow(itemId: string, qty: number) {
const { checkInventory, reserveItem, notifyBackorder } =
Durable.workflow.proxyActivities<typeof activities>({
taskQueue: 'inventory-tasks'
});
const available = await checkInventory(itemId);
if (available >= qty) {
return await reserveItem(itemId, qty);
} else {
await notifyBackorder(itemId);
return 'backordered';
}
}
// main.ts
const connection = {
class: Postgres,
options: { connectionString: 'postgresql://localhost:5432/mydb' }
};
await Durable.registerActivityWorker({
connection,
taskQueue: 'inventory-tasks'
}, activities, 'inventory-activities');
await Durable.Worker.create({
connection,
taskQueue: 'orders',
workflow: orderWorkflow
});
const client = new Durable.Client({ connection });
const handle = await client.workflow.start({
args: ['item-123', 5],
taskQueue: 'orders',
workflowName: 'orderWorkflow',
workflowId: 'order-456'
});
const result = await handle.result();
# order.yaml
activities:
trigger:
type: trigger
checkInventory:
type: worker
topic: inventory.check
reserveItem:
type: worker
topic: inventory.reserve
notifyBackorder:
type: worker
topic: inventory.backorder.notify
transitions:
trigger:
- to: checkInventory
checkInventory:
- to: reserveItem
conditions:
match:
- expected: true
actual:
'@pipe':
- ['{checkInventory.output.data.availableQty}', '{trigger.output.data.requestedQty}']
- ['{@conditional.gte}']
- to: notifyBackorder
conditions:
match:
- expected: false
actual:
'@pipe':
- ['{checkInventory.output.data.availableQty}', '{trigger.output.data.requestedQty}']
- ['{@conditional.gte}']
Deploy and run as follows:
// main.ts (reuses same activities.ts)
import * as activities from './activities';
const hotMesh = await HotMesh.init({
appId: 'orders',
engine: { connection },
workers: [
{
topic: 'inventory.check',
connection,
callback: async (data) => {
const availableQty = await activities.checkInventory(data.data.itemId);
return { metadata: { ...data.metadata }, data: { availableQty } };
}
},
{
topic: 'inventory.reserve',
connection,
callback: async (data) => {
const reservationId = await activities.reserveItem(data.data.itemId, data.data.quantity);
return { metadata: { ...data.metadata }, data: { reservationId } };
}
},
{
topic: 'inventory.backorder.notify',
connection,
callback: async (data) => {
await activities.notifyBackorder(data.data.itemId);
return { metadata: { ...data.metadata } };
}
}
]
});
await hotMesh.deploy('./order.yaml');
await hotMesh.activate('1');
const result = await hotMesh.pubsub('order.requested', {
itemId: 'item-123',
requestedQty: 5
});
Both compile to the same distributed execution model.
All snippets below run inside a workflow function (like orderWorkflow above). Durable methods are available as static imports:
import { Durable } from '@hotmeshio/hotmesh';
Long-running workflows — sleepFor is durable. The process can restart; the timer survives.
// sendFollowUp is a proxied activity from proxyActivities()
await Durable.workflow.sleepFor('30 days');
await sendFollowUp();
Parallel execution — fan out to multiple activities and wait for all results.
// proxied activities run as durable, retryable steps
const [payment, inventory, shipment] = await Promise.all([
processPayment(orderId),
updateInventory(orderId),
notifyWarehouse(orderId)
]);
Child workflows — compose workflows from other workflows.
const childHandle = await Durable.workflow.startChild(validateOrder, {
args: [orderId],
taskQueue: 'validation',
workflowId: `validate-${orderId}`
});
const validation = await childHandle.result();
Signals — pause a workflow until an external event arrives.
const approval = await Durable.workflow.waitFor<{ approved: boolean }>('manager-approval');
if (!approval.approved) return 'rejected';
Activities retry automatically on failure. Configure the policy per activity or per worker:
// Durable: per-activity retry policy
const { reserveItem } = Durable.workflow.proxyActivities<typeof activities>({
taskQueue: 'inventory-tasks',
retryPolicy: {
maximumAttempts: 5,
backoffCoefficient: 2,
maximumInterval: '60s'
}
});
// HotMesh: worker-level retry policy
const hotMesh = await HotMesh.init({
appId: 'orders',
engine: { connection },
workers: [{
topic: 'inventory.reserve',
connection,
retryPolicy: {
maximumAttempts: 5,
backoffCoefficient: 2,
maximumInterval: '60s'
},
callback: async (data) => { /* ... */ }
}]
});
Defaults: 3 attempts, coefficient 10, 120s cap. Delay formula: min(coefficient ^ attempt, maximumInterval). Duration strings like '5 seconds', '2 minutes', and '1 hour' are supported.
If all retries are exhausted, the activity fails and the error propagates to the workflow function — handle it with a standard try/catch.
Workflow state lives in your database as ordinary rows — jobs and jobs_attributes. Query it directly, back it up with pg_dump, replicate it, join it against your application tables.
SELECT
j.key AS job_key,
j.status AS semaphore,
j.entity AS workflow,
a.field AS attribute,
a.value AS value,
j.created_at,
j.updated_at
FROM
jobs j
JOIN jobs_attributes a ON a.job_id = j.id
WHERE
j.key = 'order-456'
ORDER BY
a.field;
What happened? Consult the database. What's still running? Query the semaphore. What failed? Read the row. The execution state isn't reconstructed from a log — it was committed transactionally as each step ran.
const handle = client.workflow.getHandle('orders', 'orderWorkflow', 'order-456');
const result = await handle.result(); // final output
const status = await handle.status(); // semaphore (0 = complete)
const state = await handle.state(true); // full state with metadata
const exported = await handle.export({ // selective export
allow: ['data', 'state', 'status', 'timeline']
});
There is no proprietary dashboard. Workflow state lives in Postgres, so use whatever tools you already have:
jobs and jobs_attributes to inspect state, as shown above.handle.status(), handle.state(true), and handle.export() give programmatic access to any running or completed workflow.HMSH_LOGLEVEL (debug, info, warn, error, silent) to control log verbosity.HMSH_TELEMETRY=true to emit spans and metrics. Plug in any OTel-compatible collector.For a deep dive into the transactional execution model — how every step is crash-safe, how the monotonic collation ledger guarantees exactly-once delivery, and how cycles and retries remain correct under arbitrary failure — see the Collation Design Document. The symbolic system (how to design workflows) and lifecycle details (how to deploy workflows) are covered in the Architectural Overview.
Tests run inside Docker. Start the services and run the full suite:
docker compose up -d
docker compose exec hotmesh npm test
Run a specific test group:
docker compose exec hotmesh npm run test:durable # all Durable tests
docker compose exec hotmesh npm run test:durable:hello # single Durable test (hello world)
docker compose exec hotmesh npm run test:virtual # all Virtual network function (VNF) tests
HotMesh is source-available under the HotMesh Source Available License.