mire 0.2.0

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 library for Rust. 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. Only dependency: Postgres.

Install

[dependencies]
mire = "0.1"

Pre-1.0 — the API can shift between minor releases. Pin a patch (= "0.1.0") 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

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