Storage

Overview

How Postel persists the outbox and audit trail, and how to pick a storage adapter — or bring your own.

The outbound sender needs durable storage for two things: the transactional outbox (every send() is a row workers reserve and dispatch) and the audit trail (attempts, endpoint state, replay history). Postel talks to that storage through one operation-shaped Storage interface, so the same sender runs unchanged against any backend.

@postel/core ships an in-memory Storage adapter (InMemoryStorage) — the zero-config default and the reference implementation. It's perfect for tests and single-process demos, but it doesn't survive a restart. For production you wire a database-backed adapter.

lib/postel.ts
import { Postel } from "@postel/core";
import { SqliteStorage } from "@postel/sqlite";

export const postel = Postel({
  outbound: {
    storage: SqliteStorage({ filename: "postel.db" }),
  },
});

The outbox, and why it composes with your transaction

Every adapter's write operations accept an optional tx — your application's own transaction handle. Hand it to send() and the outbox insert commits (or rolls back) atomically with your business write:

await db.transaction(async (tx) => {
  await tx.insert(orders).values({ /* ... */ });
  await postel.send({ type: "order.created", data: { /* ... */ } }, { tx });
});

No second connection, no dual-write race: if the order rolls back, so does the webhook. This is the single biggest reason Postel hands you the database client instead of opening its own.

Capabilities

Each adapter declares a capabilities object. The one that affects behavior most is notify: Postgres adapters use LISTEN/NOTIFY to wake idle workers the instant a message is enqueued; adapters without it (SQLite, MySQL) fall back to polling the outbox at a short interval. Both deliver the same messages — notify only changes dispatch latency.

Worker leases

When a worker reserves a batch it stamps each row with a lease (lease_expires_at = now + 60s by default). If the worker crashes, the lease expires and another worker reclaims the row — so a message is never lost, only ever re-attempted. This is what makes delivery at-least-once.

Picking an adapter

Three integration shapes, all implementing the same Storage interface:

CategoryPackagesPostel owns the connection?Use when
Standalone@postel/sqlite, @postel/pg, @postel/mysqlYesYou don't have a DB layer yet, or want the simplest drop-in
Query-builder / ORM@postel/kysely, @postel/drizzle, @postel/prisma, @postel/typeorm, @postel/mikro-ormNo — you hand Postel your dbYou already run Kysely / Drizzle / Prisma / TypeORM / MikroORM and want outbox writes in your transactions
Bring your owncustom adapterYour callA backend with no compatible SQL dialect (MSSQL, Oracle, a KV store, …)

Dialects and compatible databases

The query-builder / ORM adapters take a dialect"postgres", "mysql", or "sqlite". It selects a SQL dialect family (the column codec, migrations, reservation strategy, and capability flags) rather than a specific product: Postel runs raw, operation-shaped SQL through your db (the outbox primitives — FOR UPDATE SKIP LOCKED reservation, streaming queries, conditional-upsert dedup — don't decompose into portable query-builder calls), so it owns the dialect instead of delegating to the ORM.

That means any engine wire-compatible with one of the three families works by passing the matching dialect:

dialectAlso works with
"postgres"Aurora Postgres, Yugabyte, …
"mysql"MariaDB, PlanetScale / Vitess, TiDB, Aurora MySQL, …
"sqlite"libSQL, Turso, Cloudflare D1, …

Engines that diverge from all three — MSSQL, Oracle, or CockroachDB (no LISTEN/NOTIFY, different SKIP LOCKED semantics) — aren't covered by a first-party dialect yet; reach them with a custom Storage adapter. (Broadening the dialect set — and an extensible "bring your own dialect" option — is on the roadmap.)

The full canonical schema is documented in Schema & migrations.

On this page