# Overview



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 reference implementation (`storage` is a required config slot; there is no implicit default). It's perfect for tests and single-process demos, but it doesn't survive a restart. For production you wire a database-backed adapter.

```ts title="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 [#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:

```ts
await db.transaction(async (tx) => {
  await tx.insert(orders).values({ /* ... */ });
  await postel.outbound.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 [#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 [#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 [#picking-an-adapter]

Three integration shapes, all implementing the same `Storage` interface:

| Category                | Packages                                                                                                                                                                                                                   | Postel owns the connection?    | Use when                                                                                                   |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| **Standalone**          | [`@postel/sqlite`](/docs/storage/sqlite), [`@postel/pg`](/docs/storage/pg), [`@postel/mysql`](/docs/storage/mysql)                                                                                                         | Yes                            | You don't have a DB layer yet, or want the simplest drop-in                                                |
| **Query-builder / ORM** | [`@postel/kysely`](/docs/storage/kysely), [`@postel/drizzle`](/docs/storage/drizzle), [`@postel/prisma`](/docs/storage/prisma), [`@postel/typeorm`](/docs/storage/typeorm), [`@postel/mikro-orm`](/docs/storage/mikro-orm) | No — you hand Postel your `db` | You already run Kysely / Drizzle / Prisma / TypeORM / MikroORM and want outbox writes in your transactions |
| **Bring your own**      | [custom adapter](/docs/storage/custom-adapters)                                                                                                                                                                            | Your call                      | A backend with no compatible SQL dialect (MSSQL, Oracle, a KV store, …)                                    |

## Dialects and compatible databases [#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:

| `dialect`    | Also 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](/docs/storage/custom-adapters). (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](/docs/storage/schema).
