feat: event store — persist domain events to Postgres event_log table via composite publisher

This commit is contained in:
2026-05-31 18:36:10 +02:00
parent d022cb9068
commit aa09aec66b
10 changed files with 143 additions and 10 deletions

View File

@@ -0,0 +1,26 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, EventStore},
};
use std::sync::Arc;
pub struct CompositeEventPublisher {
primary: Arc<dyn EventPublisher>,
store: Arc<dyn EventStore>,
}
impl CompositeEventPublisher {
pub fn new(primary: Arc<dyn EventPublisher>, store: Arc<dyn EventStore>) -> Self {
Self { primary, store }
}
}
#[async_trait]
impl EventPublisher for CompositeEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.store.append(event).await?;
self.primary.publish(event).await
}
}

View File

@@ -1,3 +1,6 @@
pub mod composite;
pub use composite::CompositeEventPublisher;
use async_trait::async_trait;
use domain::{
errors::DomainError,

View File

@@ -4,11 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
domain = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS event_log (
event_id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_event_log_aggregate ON event_log (aggregate_id);
CREATE INDEX idx_event_log_type ON event_log (event_type);
CREATE INDEX idx_event_log_occurred ON event_log (occurred_at);

View File

@@ -0,0 +1,74 @@
use crate::helpers::{pg_repo, MapDomainError};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::EventStore,
value_objects::SystemId,
};
use event_payload::EventPayload;
use uuid::Uuid;
pg_repo!(PostgresEventStore);
/// Extracts the primary aggregate ID from a domain event.
fn aggregate_id(event: &DomainEvent) -> Uuid {
match event {
DomainEvent::AssetIngested { asset_id, .. }
| DomainEvent::MetadataUpdated { asset_id, .. }
| DomainEvent::AssetDeleted { asset_id, .. }
| DomainEvent::SidecarSyncRequested { asset_id, .. } => *asset_id.as_uuid(),
DomainEvent::ShareCreated { scope_id, .. }
| DomainEvent::ShareRevoked { scope_id, .. } => *scope_id.as_uuid(),
DomainEvent::JobEnqueued { job_id, .. }
| DomainEvent::JobCompleted { job_id, .. }
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
}
}
#[async_trait]
impl EventStore for PostgresEventStore {
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let event_type = payload.subject().to_string();
let json = serde_json::to_value(&payload)
.map_err(|e| DomainError::Internal(e.to_string()))?;
let agg_id = aggregate_id(event);
sqlx::query(
"INSERT INTO event_log (aggregate_id, event_type, payload, occurred_at)
VALUES ($1, $2, $3, now())",
)
.bind(agg_id)
.bind(event_type)
.bind(json)
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn query_by_aggregate(
&self,
aggregate_id: &SystemId,
) -> Result<Vec<DomainEvent>, DomainError> {
let rows: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT payload FROM event_log WHERE aggregate_id = $1 ORDER BY event_id ASC",
)
.bind(*aggregate_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter()
.map(|(json,)| {
let payload: EventPayload = serde_json::from_value(json)
.map_err(|e| DomainError::Internal(e.to_string()))?;
DomainEvent::try_from(payload)
})
.collect()
}
}

View File

@@ -2,6 +2,7 @@ pub mod db;
mod helpers;
pub mod catalog;
pub mod event_store;
pub mod identity;
pub mod organization;
pub mod processing;
@@ -12,6 +13,7 @@ pub mod storage;
pub use db::{PgPool, connect, run_migrations};
pub use catalog::*;
pub use event_store::PostgresEventStore;
pub use identity::*;
pub use organization::*;
pub use processing::*;