use crate::ports::{Database, DbError, Statement};
use sea_query::{Alias, Expr, Order, Query};
fn iden(name: &str) -> Alias {
Alias::new(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboxRecord {
pub id: String,
pub topic: String,
pub payload: String,
pub attempts: i64,
}
#[derive(Debug, Clone)]
pub struct Outbox {
table: String,
}
impl Outbox {
#[must_use]
pub fn new(table: impl Into<String>) -> Self {
Self {
table: table.into(),
}
}
#[must_use]
pub fn create_table_sql(&self) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table} (\n \
id TEXT PRIMARY KEY,\n \
topic TEXT NOT NULL,\n \
payload TEXT NOT NULL,\n \
subject TEXT,\n \
attempts INTEGER NOT NULL DEFAULT 0,\n \
next_attempt_at TEXT NOT NULL,\n \
locked_until TEXT,\n \
created_at TEXT NOT NULL\n);",
table = self.table
)
}
#[must_use]
pub fn enqueue_statement(
&self,
id: &str,
topic: &str,
payload: &str,
subject: Option<&str>,
at: &str,
) -> Statement {
let mut insert = Query::insert();
insert
.into_table(iden(&self.table))
.columns([
"id",
"topic",
"payload",
"subject",
"attempts",
"next_attempt_at",
"created_at",
])
.values_panic([
id.to_owned().into(),
topic.to_owned().into(),
payload.to_owned().into(),
subject.map(str::to_owned).into(),
0i64.into(),
at.to_owned().into(),
at.to_owned().into(),
]);
Statement::render(&insert)
}
pub async fn claim_due(
&self,
db: &dyn Database,
now: &str,
lease_until: &str,
limit: u64,
) -> Result<Vec<OutboxRecord>, DbError> {
let mut select = Query::select();
select
.columns([iden("id"), iden("topic"), iden("payload"), iden("attempts")])
.from(iden(&self.table))
.and_where(Expr::col(iden("next_attempt_at")).lte(now))
.and_where(
Expr::col(iden("locked_until"))
.is_null()
.or(Expr::col(iden("locked_until")).lt(now)),
)
.order_by(iden("next_attempt_at"), Order::Asc)
.limit(limit);
let rows = db.query(&Statement::render(&select)).await?;
let mut leased = Vec::new();
for row in &rows.rows {
let id = row.get::<String>("id").unwrap_or_default();
let mut lease = Query::update();
lease
.table(iden(&self.table))
.value(iden("locked_until"), lease_until)
.and_where(Expr::col(iden("id")).eq(id.as_str()))
.and_where(
Expr::col(iden("locked_until"))
.is_null()
.or(Expr::col(iden("locked_until")).lt(now)),
);
if db.execute(&Statement::render(&lease)).await? == 1 {
leased.push(OutboxRecord {
id,
topic: row.get::<String>("topic").unwrap_or_default(),
payload: row.get::<String>("payload").unwrap_or_default(),
attempts: row.get::<i64>("attempts").unwrap_or(0),
});
}
}
Ok(leased)
}
pub async fn complete(&self, db: &dyn Database, id: &str) -> Result<(), DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(&self.table))
.and_where(Expr::col(iden("id")).eq(id));
db.execute(&Statement::render(&delete)).await?;
Ok(())
}
pub async fn retry_later(
&self,
db: &dyn Database,
id: &str,
next_attempt_at: &str,
) -> Result<(), DbError> {
let mut update = Query::update();
update
.table(iden(&self.table))
.value(iden("attempts"), Expr::col(iden("attempts")).add(1))
.value(iden("next_attempt_at"), next_attempt_at)
.value(iden("locked_until"), Option::<String>::None)
.and_where(Expr::col(iden("id")).eq(id));
db.execute(&Statement::render(&update)).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_table_sql_is_portable_ddl() {
let sql = Outbox::new("mail_outbox").create_table_sql();
assert!(sql.contains("CREATE TABLE IF NOT EXISTS mail_outbox"));
assert!(sql.contains("next_attempt_at TEXT NOT NULL"));
assert!(sql.contains("locked_until TEXT"));
assert!(sql.contains("subject TEXT"));
}
#[test]
fn enqueue_statement_inserts_the_row() {
let stmt = Outbox::new("mail_outbox").enqueue_statement(
"01J",
"confirmation",
"{\"to\":\"a@b\"}",
Some("acct-1"),
"2026-09-07T00:00:00Z",
);
assert!(stmt.sql.contains("INSERT INTO"));
assert!(stmt.sql.contains("mail_outbox"));
assert!(stmt.sql.contains("subject"));
assert_eq!(stmt.values.0.len(), 7);
assert_eq!(stmt.values.0[3], "acct-1".into());
}
#[test]
fn enqueue_statement_without_a_subject_binds_null() {
let stmt = Outbox::new("mail_outbox").enqueue_statement(
"01J",
"confirmation",
"{\"to\":\"a@b\"}",
None,
"2026-09-07T00:00:00Z",
);
assert!(stmt.sql.contains("subject"));
assert_eq!(
stmt.values.0[3],
sea_query::Value::from(Option::<String>::None)
);
}
}