feat(infra): transactional outbox — OutboxWriter port, PgOutboxWriter, OutboxRelay, TestOutbox; update create_thought + delete_thought

This commit is contained in:
2026-05-15 18:31:57 +02:00
parent 15b1d0fdb7
commit 6024a65060
16 changed files with 245 additions and 20 deletions

View File

@@ -4,8 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
domain = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View File

@@ -0,0 +1,10 @@
CREATE TABLE outbox_events (
seq BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered BOOLEAN NOT NULL DEFAULT false,
delivered_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx ON outbox_events (seq) WHERE delivered = false;

View File

@@ -4,6 +4,7 @@ pub mod block;
pub mod boost;
mod db_error;
pub mod failed_event;
pub mod outbox;
pub mod feed;
pub mod follow;
pub mod like;

View File

@@ -0,0 +1,61 @@
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::OutboxWriter};
use event_payload::EventPayload;
use sqlx::PgPool;
use uuid::Uuid;
pub struct PgOutboxWriter {
pool: PgPool,
}
impl PgOutboxWriter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// Primary aggregate UUID for an event — used to populate `aggregate_id`.
fn aggregate_id(event: &DomainEvent) -> Uuid {
match event {
DomainEvent::ThoughtCreated { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::ThoughtDeleted { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::ThoughtUpdated { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::LikeAdded { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::LikeRemoved { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::BoostAdded { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::BoostRemoved { thought_id, .. } => thought_id.as_uuid(),
DomainEvent::FollowRequested { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::FollowAccepted { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::FollowRejected { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::Unfollowed { follower_id, .. } => follower_id.as_uuid(),
DomainEvent::UserBlocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserRegistered { user_id } => user_id.as_uuid(),
DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(),
DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(),
}
}
#[async_trait]
impl OutboxWriter for PgOutboxWriter {
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let event_type = payload.subject();
let payload_json =
serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
let agg_id = aggregate_id(event);
sqlx::query(
"INSERT INTO outbox_events (aggregate_id, event_type, payload) \
VALUES ($1, $2, $3)",
)
.bind(agg_id)
.bind(event_type)
.bind(payload_json)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}