backbone_outbox/lib.rs
1//! # backbone-outbox
2//!
3//! Durable **transactional-outbox + relay + inbox** primitives that make cross-module event delivery
4//! go-live safe over Postgres — without a broker. It composes the in-process `backbone-messaging` bus;
5//! it does not replace it.
6//!
7//! The three moving parts (see `docs/erp/event-bus-contract.md`):
8//!
9//! 1. **Outbox** — [`outbox::stage`] writes a serialized event into `<schema>.outbox_events` **inside
10//! the producer's own transaction**, so the state change and the event commit atomically. No event is
11//! lost on a crash between commit and publish, and none is emitted for a rolled-back change.
12//! 2. **Relay** — [`relay::drain_once`] drains un-published rows (`FOR UPDATE SKIP LOCKED`) onto a
13//! caller-supplied `publish` sink (the in-proc bus today, a broker later) and marks them published.
14//! **At-least-once** delivery.
15//! 3. **Inbox** — [`inbox::once`] dedups a `(consumer, event_id)` in the consumer's own transaction, so
16//! at-least-once delivery becomes an **exactly-once effect**.
17//!
18//! **When the transport gives up.** A `publish` sink that has exhausted its own retries returns
19//! [`OutboxError::Exhausted`], and the relay marks that row dead — `failed_at` set, `published_at`
20//! still NULL — instead of publishing it. This matters because the two alternatives both lose work
21//! silently: answering `Ok` stamps the row published, so a dropped effect reads as delivered and the
22//! only record of it is gone, while answering [`OutboxError::Publish`] re-hands the same doomed
23//! record on every pass forever. A dead row is excluded from the drain, counted by
24//! [`outbox::dead_count`], listed by [`relay::list_dead`], and handed back by [`relay::reemit`] once
25//! the cause is fixed. Nothing revives it on its own — that is the point of a dead letter.
26//!
27//! `backbone-outbox` is framework plumbing: it depends on `sqlx` + `serde` only, never on a domain
28//! module or the bus. The relay's transport is a closure the caller wires.
29//!
30//! ```no_run
31//! # async fn ex(pool: &sqlx::PgPool) -> Result<(), backbone_outbox::OutboxError> {
32//! use backbone_outbox::{outbox, inbox, relay, OutboxRecord};
33//! use chrono::Utc;
34//! use uuid::Uuid;
35//!
36//! // Producer: stage in the same tx as the state change.
37//! let company_id = Uuid::new_v4();
38//! let mut tx = pool.begin().await?;
39//! // ... mutate state on &mut *tx ...
40//! let rec = OutboxRecord::new("PaymentSettled", "Payment", "pay-1", company_id,
41//! serde_json::json!({"invoice": "INV-1", "amount": "100.00"}), Utc::now());
42//! outbox::stage(&mut *tx, "payment", &rec).await?;
43//! tx.commit().await?;
44//!
45//! // Relay: drain onto the bus (here: a no-op sink).
46//! relay::drain_once(pool, "payment", 100, |_rec: OutboxRecord| async { Ok(()) }).await?;
47//!
48//! // Consumer: dedup then apply, atomically.
49//! let mut ctx = pool.begin().await?;
50//! if inbox::once(&mut *ctx, "billing", "billing-settler", rec.id).await? {
51//! // ... apply_settlement on &mut *ctx ...
52//! }
53//! ctx.commit().await?;
54//! # Ok(()) }
55//! ```
56
57pub mod error;
58pub mod inbox;
59pub mod outbox;
60pub mod record;
61pub mod relay;
62#[cfg(feature = "runner")]
63pub mod runner;
64
65pub use error::{OutboxError, Result};
66pub use record::OutboxRecord;