reliar-store-postgres 0.1.0

PostgreSQL provider for the Reliar transactional outbox: migrations, migrate(), enqueue and the SKIP LOCKED claim.
Documentation
# reliar-store-postgres

Reliar's PostgreSQL provider for the transactional outbox: the schema, the explicit `migrate()`
API, and `PostgresOutboxStore` — the only crate in the workspace where an `sqlx`/Postgres type
appears (SRS §20–§26, §35).

**MSRV 1.94**, set by `sqlx` 0.9 — six releases above the workspace floor of 1.88. Provider
crates may carry their driver's MSRV; the pure `reliar-core`/`reliar-outbox` crates stay on 1.88
(ADR 0025).

## Guarantees this store honours

`PostgresOutboxStore` implements `reliar-outbox`'s contract, so its two headline guarantees are
`reliar-outbox`'s, not restated differently here — see `../reliar-outbox/README.md` for the full
text. In short:

- **Durable at-least-once publication, never exactly-once.** A consumer built on Reliar must be
  idempotent. Three windows produce a duplicate and all three are unavoidable in this release:
  the **crash window** (a publish reaches the broker, the worker crashes before `complete`
  persists, the lease expires, and another worker republishes), the **slow-batch window** (a
  batch outlives its lease while the worker is still healthily publishing, so a second worker
  reclaims and republishes the tail), and the **drain window** (cancellation drains in-flight
  publishes for at most `drain_timeout`; one still unresolved at the timeout is released rather
  than awaited further, carrying the same duplicate risk, just triggered by shutdown).
- **No ordering by default.** `Ordering::Unordered` (the only value this release supports)
  guarantees nothing about order — not globally, not per `conversation_id`, not per aggregate,
  not even approximately: `acquire`'s `SKIP LOCKED` claim, concurrent publishing, per-message
  backoff and multiple dispatcher instances each reorder freely.

## Features

| Feature | Default | Enables |
|---|---|---|
| `json` | **on** | `PostgresOutboxStore<JsonSerializer>`'s default type parameter and the `new`/`with_settings` convenience constructors (forwards `reliar-core/json`). Not hard-enabled: a deployment supplying its own `Serializer` should not pull in `serde_json`. Under `--no-default-features`, [`PostgresOutboxStore::connect`] is the only constructor. |
| `serde` | off | `serde::Serialize`/`Deserialize` on `PostgresOutboxSettings`, `#[serde(default, deny_unknown_fields)]` so a typo'd config key is a hard error, durations as integer milliseconds (`statement_timeout_ms`). `serde` itself is always a dependency regardless of this feature — it also drives the crate's private `MetadataRest` JSONB contract (ADR 0012), which is not feature-gated. |

Additive, checked with `cargo hack check --feature-powerset`.

## `search_path` setup

Every Reliar object lives in **one configurable schema, `reliar` by default**, with unprefixed
table names (`outbox`). `sqlx::query!` checks SQL at compile time, so every identifier in every
statement is a static, unqualified literal — the schema is resolved at connection time through
`search_path`, never compiled in (ADR 0017).

1. **Put `reliar` first on the connection URL** the host passes to its own pool:

   ```text
   postgres://user:pw@host/app?options=-c%20search_path%3Dreliar,public
   ```

2. **Behind a transaction-mode pooler** — any pooler that drops startup `options` needs a
   server-side default instead, which every pooler mode honours (verify which yours does:
   `PgDog`, the pooler the suite tests against, passes them through):

   ```sql
   ALTER ROLE app SET search_path = reliar, public;
   ```

3. `PostgresOutboxStore::connect`/`new` verify, **once at construction**, that the unqualified
   name `outbox` resolves to the configured schema. An unresolvable name or a mismatch is a
   construction error naming the configured schema, the observed `search_path`, and the `ALTER
   ROLE` remedy — never a surprise failure on the first `acquire`. A same-named table found in
   another schema on the path is logged as a `tracing::warn!`.

4. `migrate()` does **not** depend on the caller's `search_path` — it creates the schema itself
   and sets `search_path` on its own dedicated connection before running the migration files, so
   it works even against a pool whose URL never set one (ADR 0018).

## Usage

```rust,no_run
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let pool = sqlx::PgPool::connect("postgres://...").await?;

reliar_store_postgres::migrate(&pool, reliar_store_postgres::MigrateOptions::default()).await?;

let store = reliar_store_postgres::PostgresOutboxStore::new(pool.clone()).await?;

let mut tx = pool.begin().await?;
// ... write your own business row(s) in the same transaction ...
let envelope = reliar_core::Envelope::builder(/* your Message */ todo!()).build();
store.enqueue(&mut tx, &envelope).await?;
tx.commit().await?;
# Ok(()) }
```

## What this crate ships (S5 + S6)

- `migrations/0001_outbox.sql` — the full v0.1 schema (SRS §24.1), every constraint/index
  explicitly named (`pk_`/`ck_`/`ix_`).
- `migrate(&pool, MigrateOptions)` — isolated bookkeeping in `<schema>._migrations`, never the
  shared `_sqlx_migrations`.
- `PostgresOutboxStore::connect`/`new`/`with_settings` — fail-fast startup `search_path`
  verification.
- `enqueue`/`enqueue_with` — the transactional write path.
- The full `OutboxStore` impl: `acquire` (the single-statement `FOR UPDATE SKIP LOCKED` claim,
  with poisoned-row handling — an undecodable row is moved to dead with
  `DeadReason::Undecodable` and reported, never a panic, the rest of the batch still delivers),
  worker-guarded `complete`/`fail`/`release`/`extend_lease`, bounded `purge` (one pass, three
  `LIMIT`-capped statements), and `stats`.
- The full `OutboxDeadLetters` impl: `list_dead` (keyset-paginated, `ORDER BY sequence`),
  `retry_dead`, `purge_dead`.
- Per-variant `Classify` for `PostgresStoreError`/`EnqueueError`, including SQLSTATE-class-based
  classification of `Database` errors and `42P01``NotMigrated` on every operational path.

## Testing

Real-Postgres integration tests live in `tests/` (skill `testcontainers`): one ephemeral
`postgres:18-alpine` container per test binary, or `DATABASE_URL` when set (CI's service
container), with one isolated database per test, migrated via the crate's own public `migrate()`.
Run with Docker available:

```sh
cargo test -p reliar-store-postgres --all-features
```

## `.sqlx/` offline cache

```sh
cd crates/reliar-store-postgres
DATABASE_URL=postgres://user:pw@localhost/db?options=-c%20search_path%3Dreliar,public \
  cargo sqlx prepare -- --all-targets --all-features
git add .sqlx
```

CI builds with `SQLX_OFFLINE=true` and runs `cargo sqlx prepare --check` against a freshly
migrated database.