Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ emitter = ["dep:event-emitter-rs"]
bus = []
http = ["bus", "dep:axum", "dep:tokio"]
grpc = ["bus", "dep:tonic", "dep:prost", "dep:tokio"]
postgres = ["dep:sqlx", "dep:tokio", "sqlx/postgres", "sqlx/runtime-tokio"]
sqlite = ["dep:sqlx", "dep:tokio", "sqlx/runtime-tokio", "sqlx/sqlite"]

[dependencies]
axum = { version = "0.7", optional = true }
Expand All @@ -41,6 +43,7 @@ event-emitter-rs = { version = "0.1.4", optional = true }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
sourced_rust_macros = { workspace = true }
sqlx = { version = "0.8", default-features = false, optional = true }
tonic = { version = "0.12", optional = true }
prost = { version = "0.13", optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"], optional = true }
Expand Down
14 changes: 14 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: sourced
POSTGRES_PASSWORD: sourced
POSTGRES_DB: sourced_rust
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sourced -d sourced_rust"]
interval: 2s
timeout: 5s
retries: 20
43 changes: 43 additions & 0 deletions docs/async-repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,46 @@ stream-aware contract before SQL code lands.
The Postgres repository should implement the async traits directly with `sqlx`.
It should not hide database I/O behind the synchronous traits with `block_on`,
`block_in_place`, or a blocking wrapper in normal async runtimes.

## SQLite Adapter

The optional `sqlite` feature exports `SqliteRepository`, an async-only
SQL-backed adapter for local persistence and conformance work:

```rust
let repo = sourced_rust::SqliteRepository::connect_and_migrate("sqlite::memory:").await?;
```

`SqliteRepository::migrate` applies explicit SQLite migrations from
`migrations/sqlite`. Plain construction from an existing pool does not create
tables implicitly, so applications can control bootstrap order.

The first SQLite pass persists aggregate events, transactional document read
models, processed-message marks, and snapshots in one SQL transaction when they
are staged through `AsyncCommitBatch`. It intentionally does not claim Postgres
production readiness: Postgres-specific column types, isolation behavior, error
mapping, deployment, and migration validation still belong to the Postgres
adapter and its own tests.

## Postgres Adapter

The optional `postgres` feature exports `PostgresRepository`, an async-only
SQLx adapter for the production SQL event-store path:

```rust
let repo =
sourced_rust::PostgresRepository::connect_and_migrate(database_url).await?;
```

Local integration tests can use the root `compose.yaml` service:

```bash
docker compose up -d postgres
DATABASE_URL=postgres://sourced:sourced@localhost:5432/sourced_rust \
cargo test --features postgres --test postgres_repository
```

The first Postgres pass persists aggregate event streams and snapshots through
explicit migrations in `migrations/postgres`. It rejects non-empty read-model
write plans instead of creating generic read-model tables implicitly; durable
read-model persistence remains a separate adapter track.
37 changes: 37 additions & 0 deletions migrations/postgres/0001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS aggregate_events (
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
sequence bigint NOT NULL,
event_name text NOT NULL,
event_version integer NOT NULL DEFAULT 1,
payload bytea NOT NULL,
payload_codec text NOT NULL,
payload_codec_version integer NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
recorded_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (aggregate_type, aggregate_id, sequence),
CHECK (aggregate_type <> ''),
CHECK (aggregate_id <> ''),
CHECK (sequence > 0),
CHECK (event_version > 0),
CHECK (payload_codec <> ''),
CHECK (payload_codec_version > 0)
);

CREATE INDEX IF NOT EXISTS aggregate_events_event_version_idx
ON aggregate_events (aggregate_type, event_name, event_version);

CREATE INDEX IF NOT EXISTS aggregate_events_recorded_at_idx
ON aggregate_events (recorded_at);

CREATE TABLE IF NOT EXISTS aggregate_snapshots (
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
version bigint NOT NULL,
data bytea NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (aggregate_type, aggregate_id),
CHECK (aggregate_type <> ''),
CHECK (aggregate_id <> ''),
CHECK (version > 0)
);
58 changes: 58 additions & 0 deletions migrations/sqlite/0001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS aggregate_events (
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
event_name TEXT NOT NULL,
event_version INTEGER NOT NULL DEFAULT 1,
payload BLOB NOT NULL,
payload_codec TEXT NOT NULL,
payload_codec_version INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (aggregate_type, aggregate_id, sequence),
CHECK (aggregate_type <> ''),
CHECK (aggregate_id <> ''),
CHECK (sequence > 0),
CHECK (event_version > 0),
CHECK (payload_codec <> ''),
CHECK (payload_codec_version > 0)
);

CREATE INDEX IF NOT EXISTS aggregate_events_event_version_idx
ON aggregate_events (aggregate_type, event_name, event_version);

CREATE INDEX IF NOT EXISTS aggregate_events_recorded_at_idx
ON aggregate_events (recorded_at);

CREATE TABLE IF NOT EXISTS transactional_read_models (
collection TEXT NOT NULL,
id TEXT NOT NULL,
version INTEGER NOT NULL,
payload BLOB NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection, id),
CHECK (collection <> ''),
CHECK (id <> ''),
CHECK (version > 0)
);

CREATE TABLE IF NOT EXISTS read_model_processed_messages (
consumer_name TEXT NOT NULL,
message_id TEXT NOT NULL,
processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (consumer_name, message_id),
CHECK (consumer_name <> ''),
CHECK (message_id <> '')
);

CREATE TABLE IF NOT EXISTS aggregate_snapshots (
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
version INTEGER NOT NULL,
data BLOB NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (aggregate_type, aggregate_id),
CHECK (aggregate_type <> ''),
CHECK (aggregate_id <> ''),
CHECK (version > 0)
);
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ pub mod lock;
pub mod microsvc;
mod outbox;
mod outbox_worker;
#[cfg(feature = "postgres")]
pub mod postgres_repo;
pub mod queued_repo;
pub mod read_model;
pub mod snapshot;
#[cfg(feature = "sqlite")]
pub mod sqlite_repo;
#[cfg(any(feature = "postgres", feature = "sqlite"))]
mod sqlx_repo;

// Re-export entity types at crate root for convenience
pub use entity::{
Expand All @@ -46,6 +52,10 @@ pub use aggregate::{
};

pub use hashmap_repo::HashMapRepository;
#[cfg(feature = "postgres")]
pub use postgres_repo::PostgresRepository;
#[cfg(feature = "sqlite")]
pub use sqlite_repo::SqliteRepository;

// Re-export lock traits and types at crate root for convenience
pub use lock::{InMemoryLock, InMemoryLockManager, Lock, LockError, LockManager};
Expand Down
Loading