mire 0.2.4

A small, generic PostgreSQL event-sourcing library: append-only event streams, aggregates with optimistic concurrency, and subscription-based projections (requires tokio + sqlx)
Documentation

mire

A small PostgreSQL event-sourcing system for Rust, in two layers:

  • mire — the event store. Append-only event streams, optimistic concurrency, snapshots, replica-safe projections via lease + fence-token, and an escape hatch for the rare read-before-write case. Backed by sqlx + tokio; the only dependency is Postgres. This is the focus of this README.
  • mire-sagas — a saga / process-manager layer on top of mire, for workflows that span several aggregates (reserve → pay → confirm, with compensation and exactly-once effects). Optional — see below.

The two compose: a saga is itself just a mire aggregate (its state is an event stream), so everything you learn about the event store below carries over.

For the whole picture — the life of an event and of a saga, the guarantees and where they stop, where things fail and how they recover — see docs/ARCHITECTURE.md; for the operator's checklist of what you must get right, see docs/SUCCESS.md.

Install

[dependencies]
mire = "0.2"

Pre-1.0 — the API can shift between minor releases. Pin a patch (= "0.2.4") if you need stability across the next change.

Quickstart

use mire::{Aggregate, EventData, EventStore};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;

#[derive(Debug, Clone, Serialize, Deserialize, EventData)]
#[serde(tag = "type")]
#[mire(entity = "account")]
enum AccountEvent {
    Opened { owner: String },
    Deposited { amount: i64 },
}

#[derive(Debug, Default)]
struct Account {
    owner: String,
    balance: i64,
}

impl Aggregate for Account {
    type Event = AccountEvent;
    fn stream_category() -> &'static str { "account" }
    fn apply(&mut self, event: &AccountEvent) {
        match event {
            AccountEvent::Opened { owner } => self.owner = owner.clone(),
            AccountEvent::Deposited { amount } => self.balance += amount,
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let pool = PgPool::connect("postgres://…").await?;
    let store = EventStore::new(pool);
    store.migrate().await?;                          // once at startup

    let mut account = store.load_or_default::<Account>("acc-123").await?;
    account.record(AccountEvent::Opened { owner: "Ada".into() });
    account.record(AccountEvent::Deposited { amount: 100 });
    store.save(&mut account).await?;

    let reloaded = store.load::<Account>("acc-123").await?.unwrap();
    assert_eq!(reloaded.state.balance, 100);
    Ok(())
}

That's the shape of every interaction with mire: load → record → save, and load again to replay state from the log.

Patterns

Each example below is a self-contained slice — one concept, one runnable program, a short "why this pattern exists" block at the top. Pick the one matching what you're trying to do.

Pattern When to use Example
Aggregate lifecycle The minimum shape. Defining events, an aggregate, load/record/save. Every other pattern builds on this. bank_account
Commands & validation Refusing invalid operations before recording events. The right place for "can't overdraft", "can't operate on a closed account", etc. commands
Concurrency conflicts Two writers race on the same aggregate. mire uses optimistic concurrency — no locks; the loser retries. concurrency
Projections (read models) Aggregates are great for writing; terrible for querying. Build a query-optimised table the runner keeps current. projection
Snapshots A single aggregate has accumulated >1k events and load is on a hot path. Cache the folded state so loads stay O(1). snapshot
Read-before-write across aggregates One business decision spans two aggregates (e.g. atomic money transfer). The escape hatch — used sparingly. transaction_scope
Multi-replica deployment Production runs multiple replicas. Exactly one drives each projection at a time, with automatic failover. multi_replica
High-throughput writes Bulk ingest. Calling save once per event is 10× slower than necessary; batch them. batched_write

Running the examples

mise run pg:up           # starts a local Postgres on :5434 via docker compose
cargo run --example bank_account
cargo run --example commands
# … etc.

If you don't use mise:

docker compose up -d postgres
export DATABASE_URL=postgres://mire:mire@localhost:5434/mire
cargo run --example bank_account

Sagas

When a single business process spans several aggregates — reserve inventory, capture payment, issue a ticket, and roll all of it back if any step fails — that orchestration belongs in no single aggregate. mire-sagas is a Postgres-backed process manager for exactly that, built on the event store above.

A saga is a declarative DAG of steps. Each step splits into two halves:

  • a pure, synchronous decide that folds state into a typed request (it can't perform IO, so it's always safe to re-run on recovery), and
  • an async effect, co-located via .effect(...), that does the IO.

On top of that the runner gives you compensations (automatic rollback in reverse order), timeouts, external signals, sharded scale-out across replicas, and exactly-once effects under crashes and concurrency (via an instance lease + OCC + idempotency keys).

[dependencies]
mire-sagas = "0.2"

See the mire-sagas README for the model and a fully-typed example, plus the runnable travel_booking (minimal, in-process effects) and travel_booking_service (real providers over HTTP, durable outbox, chaos-tested) examples.

What mire is not

  • Not a queue. Events go into a per-stream log, not a Kafka-style topic. Use mire when your durable state IS your event log.
  • Not multi-region. Single Postgres cluster, with the usual Postgres-replication options for read replicas / DR.
  • Not a CRUD ORM. State lives in events; aggregates are reconstructed by replay. If you want to UPDATE … SET …, use sqlx directly.

License

Licensed under MIT