Postgres
@postel/pg — a standalone Postgres storage adapter built on node-postgres, with SKIP LOCKED reservation and LISTEN/NOTIFY.
@postel/pg is a standalone adapter for Postgres: hand it a pg.Pool you already manage, or a connection string and Postel owns the pool. It's the adapter for multi-node, high-throughput delivery.
pnpm add @postel/pg pgnpm install @postel/pg pgyarn add @postel/pg pgbun add @postel/pg pgHand PgStorage a pg.Pool your app already manages — Postel reuses it, so the outbox insert can share your transactions:
import { Pool } from "pg";
import { Postel } from "@postel/core";
import { PgStorage } from "@postel/pg";
import { config } from "./config.js";
const pool = new Pool({ connectionString: config.databaseUrl });
export const postel = Postel({
outbound: {
storage: PgStorage({ pool }),
},
});Or pass a connection string and let Postel open and own the pool:
import { Postel } from "@postel/core";
import { PgStorage } from "@postel/pg";
import { config } from "./config.js";
export const postel = Postel({
outbound: {
storage: PgStorage({ connectionString: config.databaseUrl }),
},
});Options
| Option | Notes |
|---|---|
connectionString | Postel opens and owns a pg.Pool for this URL. |
pool | An existing node-postgres pg.Pool to reuse instead — pass this to share a connection (and transactions) with your app. |
autoMigrate | Run migrations on first use (default true). |
clock | Inject a clock for deterministic time in tests. |
Pass exactly one of connectionString or pool. pg (node-postgres) is a peer dependency you install alongside.
SKIP LOCKED reservation
Workers reserve outbox rows with UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED …). Concurrent workers each grab a disjoint batch without blocking on one another — this is what lets you scale delivery horizontally across many worker processes against one database.
LISTEN/NOTIFY
The adapter declares capabilities.notify = true. Every send() fires NOTIFY postel_messages_new, and idle workers LISTEN for it — so a freshly enqueued message is picked up with near-zero latency instead of waiting for the next poll tick.
Transactions compose with yours
Because Postel runs through node-postgres, you can share a transaction: open one on a client and pass it to send() so the outbox insert commits atomically with your business writes. See the Storage overview.
Receiver-side dedup
@postel/pg also exports PgDedup (and ensurePgDedupTable) — the inbound idempotency-dedup helper, independent of the outbound storage. Wire it onto an inbound source, reusing the same pool:
import { PgDedup } from "@postel/pg";
const postel = Postel({
inbound: {
vendor: {
verify: Secret(config.webhookSecret),
dedup: PgDedup({
client: pool,
// tableName: "postel_received_messages", (default)
// schema: undefined, (search_path)
// autoMigrate: true, (creates the table on first use)
}),
dedupTtl: "24h",
},
},
});The adapter uses INSERT … ON CONFLICT … DO UPDATE WHERE expires_at <= now() — atomic at the DB level, no application-level locks. The table is two columns: message_id text PRIMARY KEY, expires_at timestamptz, with an index on expires_at for cheap cleanup. See Deduplication for the dedup contract and where to place the call.