use async_trait::async_trait;
use serde_json::Value;
use crate::database::DbPool;
use crate::foundation::{DbError, DbResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityAction {
Insert,
Update,
Delete,
}
impl EntityAction {
pub fn as_str(self) -> &'static str {
match self {
EntityAction::Insert => "insert",
EntityAction::Update => "update",
EntityAction::Delete => "delete",
}
}
pub fn from_str_raw(text: &str) -> Option<Self> {
match text {
"insert" => Some(EntityAction::Insert),
"update" => Some(EntityAction::Update),
"delete" => Some(EntityAction::Delete),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct EntityEvent {
pub entity: String,
pub action: EntityAction,
pub entity_id: String,
pub payload: Option<Value>,
pub occurred_at_ms: u64,
}
impl EntityEvent {
pub fn new(entity: &str, action: EntityAction, entity_id: &str) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or_default();
Self {
entity: entity.to_string(),
action,
entity_id: entity_id.to_string(),
payload: None,
occurred_at_ms: now,
}
}
pub fn insert(entity: &str, entity_id: &str) -> Self {
Self::new(entity, EntityAction::Insert, entity_id)
}
pub fn update(entity: &str, entity_id: &str) -> Self {
Self::new(entity, EntityAction::Update, entity_id)
}
pub fn delete(entity: &str, entity_id: &str) -> Self {
Self::new(entity, EntityAction::Delete, entity_id)
}
pub fn with_payload(mut self, payload: Value) -> Self {
self.payload = Some(payload);
self
}
}
#[async_trait]
pub trait EntityEventBus: Send + Sync {
async fn publish(&self, event: &EntityEvent) -> DbResult<()>;
}
#[derive(Default)]
pub struct InMemoryEntityEventBus {
senders: tokio::sync::Mutex<Vec<tokio::sync::mpsc::UnboundedSender<EntityEvent>>>,
}
impl InMemoryEntityEventBus {
pub async fn subscribe(&self) -> tokio::sync::mpsc::UnboundedReceiver<EntityEvent> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
self.senders.lock().await.push(tx);
rx
}
}
#[async_trait]
impl EntityEventBus for InMemoryEntityEventBus {
async fn publish(&self, event: &EntityEvent) -> DbResult<()> {
let mut senders = self.senders.lock().await;
senders.retain(|tx| !tx.is_closed());
for tx in senders.iter() {
tx.send(event.clone())
.map_err(|_| DbError::Config("entity event subscriber dropped".to_string()))?;
}
Ok(())
}
}
#[async_trait]
pub trait OutboxStore: Send + Sync {
async fn record(&self, event: &EntityEvent) -> DbResult<u64>;
async fn fetch_pending(&self, limit: u64) -> DbResult<Vec<(u64, EntityEvent)>>;
async fn mark_dispatched(&self, id: u64) -> DbResult<()>;
}
#[derive(Clone)]
pub struct DbOutboxStore {
pool: std::sync::Arc<DbPool>,
table: String,
}
impl DbOutboxStore {
pub async fn ensure_table(&self) -> DbResult<()> {
let sql = format!(
"CREATE TABLE IF NOT EXISTS {} (id INTEGER PRIMARY KEY AUTOINCREMENT, entity TEXT NOT NULL, action TEXT NOT NULL, entity_id TEXT NOT NULL, payload TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER)",
self.table
);
let session = self.pool.get_session("admin").await?;
session.execute_raw_ddl(&sql).await?;
Ok(())
}
pub fn new(pool: std::sync::Arc<DbPool>) -> DbResult<Self> {
Self::with_table(pool, "dbnexus_outbox")
}
pub fn with_table(pool: std::sync::Arc<DbPool>, table: &str) -> DbResult<Self> {
if crate::database::repository::is_safe_identifier(table) {
Ok(Self {
pool,
table: table.to_string(),
})
} else {
Err(DbError::Config(format!(
"outbox table name must be a safe identifier: '{table}'"
)))
}
}
fn sql_value(v: &Value) -> String {
crate::database::repository::sql_literal(v).unwrap_or_else(|_| "NULL".to_string())
}
}
#[async_trait]
impl OutboxStore for DbOutboxStore {
async fn record(&self, event: &EntityEvent) -> DbResult<u64> {
let payload = match &event.payload {
Some(v) => Self::sql_value(v),
None => "NULL".to_string(),
};
let entity = Self::sql_value(&Value::String(event.entity.clone()));
let entity_id = Self::sql_value(&Value::String(event.entity_id.clone()));
let sql = format!(
"INSERT INTO {} (entity, action, entity_id, payload, status, created_at) VALUES ({}, '{}', {}, {}, 'pending', {})",
self.table,
entity,
event.action.as_str(),
entity_id,
payload,
event.occurred_at_ms
);
let session = self.pool.get_session("admin").await?;
let exec = session.execute_raw(&sql).await?;
Ok(exec.last_insert_id() as u64)
}
async fn fetch_pending(&self, limit: u64) -> DbResult<Vec<(u64, EntityEvent)>> {
let sql = format!(
"SELECT id, entity, action, entity_id, payload, created_at FROM {} WHERE status = 'pending' ORDER BY id LIMIT {}",
self.table, limit
);
let rows = self.pool.query_rows(&sql, "admin").await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let id = row.get("id").and_then(|v| v.as_u64()).unwrap_or_default();
let Some(entity) = row.get("entity").and_then(|v| v.as_str().map(String::from)) else {
continue;
};
let Some(action_text) = row.get("action").and_then(|v| v.as_str().map(String::from))
else {
continue;
};
let Some(action) = EntityAction::from_str_raw(&action_text) else {
continue;
};
let Some(entity_id) = row
.get("entity_id")
.and_then(|v| v.as_str().map(String::from))
else {
continue;
};
let payload: Option<Value> = match row
.get("payload")
.and_then(|v| v.as_str().map(String::from))
{
Some(text) => {
Some(serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text)))
}
None => None,
};
let occurred_at_ms = row
.get("created_at")
.and_then(|v| v.as_u64())
.unwrap_or_default();
out.push((
id,
EntityEvent {
entity,
action,
entity_id,
payload,
occurred_at_ms,
},
));
}
Ok(out)
}
async fn mark_dispatched(&self, id: u64) -> DbResult<()> {
let sql = format!(
"UPDATE {} SET status = 'dispatched' WHERE id = {}",
self.table, id
);
let session = self.pool.get_session("admin").await?;
session.execute_raw(&sql).await?;
Ok(())
}
}
pub struct OutboxDispatcher;
impl OutboxDispatcher {
pub async fn dispatch_once(
store: &dyn OutboxStore,
bus: &dyn EntityEventBus,
limit: u64,
) -> DbResult<u64> {
let pending = store.fetch_pending(limit).await?;
let mut dispatched = 0u64;
for (id, event) in pending {
bus.publish(&event).await?;
store.mark_dispatched(id).await?;
dispatched += 1;
}
Ok(dispatched)
}
pub fn spawn(
store: std::sync::Arc<dyn OutboxStore>,
bus: std::sync::Arc<dyn EntityEventBus>,
interval_ms: u64,
batch: u64,
shutdown: std::sync::Arc<tokio::sync::Notify>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let _ = Self::dispatch_once(store.as_ref(), bus.as_ref(), batch).await;
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_millis(interval_ms)) => {}
_ = shutdown.notified() => break,
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entity_action_roundtrip() {
for action in [
EntityAction::Insert,
EntityAction::Update,
EntityAction::Delete,
] {
assert_eq!(EntityAction::from_str_raw(action.as_str()), Some(action));
}
assert_eq!(EntityAction::from_str_raw("bogus"), None);
}
}