use cratefield_core::{Database, DbError, IdGen, Outbox, Row, Statement, UlidIdGen};
use sea_query::{Alias, Expr, Order, Query};
use serde::{Deserialize, Serialize};
pub(crate) const ENDPOINTS: &str = "webhooks_endpoints";
pub(crate) const OUTBOX: &str = "webhooks_outbox";
pub(crate) const DELIVERIES: &str = "webhooks_deliveries";
pub(crate) const DEAD_LETTERS: &str = "webhooks_dead_letters";
pub const TOPIC_DELIVER: &str = "webhooks.deliver";
fn iden(name: &str) -> Alias {
Alias::new(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub id: String,
pub subject: String,
pub url: String,
pub event_types: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delivery {
pub id: String,
pub endpoint_id: String,
pub subject: String,
pub event_id: String,
pub event_type: String,
pub attempt: i64,
pub status_code: Option<i64>,
pub error: Option<String>,
pub at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeadLetterReason {
AttemptsExhausted,
Rejected,
Malformed,
}
impl DeadLetterReason {
pub(crate) fn as_str(self) -> &'static str {
match self {
DeadLetterReason::AttemptsExhausted => "attempts_exhausted",
DeadLetterReason::Rejected => "rejected",
DeadLetterReason::Malformed => "malformed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeadLetter {
pub id: String,
pub endpoint_id: String,
pub subject: String,
pub event_id: String,
pub event_type: String,
pub data: String,
pub attempts: i64,
pub reason: String,
pub last_error: String,
pub status_code: Option<i64>,
pub created_at: String,
pub failed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveryJob {
pub endpoint_id: String,
pub event_id: String,
pub event_type: String,
pub subject: String,
pub created_at: String,
pub data: serde_json::Value,
}
const ENDPOINT_COLUMNS: [&str; 5] = ["id", "subject", "url", "event_types", "created_at"];
const DELIVERY_COLUMNS: [&str; 9] = [
"id",
"endpoint_id",
"subject",
"event_id",
"event_type",
"attempt",
"status_code",
"error",
"at",
];
const DEAD_LETTER_COLUMNS: [&str; 12] = [
"id",
"endpoint_id",
"subject",
"event_id",
"event_type",
"data",
"attempts",
"reason",
"last_error",
"status_code",
"created_at",
"failed_at",
];
pub(crate) struct DeliveryTarget {
pub url: String,
pub secret: String,
}
pub(crate) async fn delivery_target(
db: &dyn Database,
subject: &str,
endpoint_id: &str,
) -> Result<Option<DeliveryTarget>, DbError> {
let mut select = Query::select();
select
.columns(["url", "secret"])
.from(iden(ENDPOINTS))
.and_where(Expr::col(iden("subject")).eq(subject))
.and_where(Expr::col(iden("id")).eq(endpoint_id));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.map(|row| DeliveryTarget {
url: row.get::<String>("url").unwrap_or_default(),
secret: row.get::<String>("secret").unwrap_or_default(),
}))
}
pub(crate) async fn endpoint_exists(
db: &dyn Database,
subject: &str,
endpoint_id: &str,
) -> Result<bool, DbError> {
let mut select = Query::select();
select
.column(iden("id"))
.from(iden(ENDPOINTS))
.and_where(Expr::col(iden("subject")).eq(subject))
.and_where(Expr::col(iden("id")).eq(endpoint_id));
Ok(!db.query(&Statement::render(&select)).await?.rows.is_empty())
}
pub(crate) async fn endpoints_for_subject(
db: &dyn Database,
subject: &str,
) -> Result<Vec<Endpoint>, DbError> {
let mut select = Query::select();
select
.columns(ENDPOINT_COLUMNS)
.from(iden(ENDPOINTS))
.and_where(Expr::col(iden("subject")).eq(subject))
.order_by(iden("created_at"), Order::Asc)
.order_by(iden("id"), Order::Asc);
let rows = db.query(&Statement::render(&select)).await?;
Ok(rows.rows.iter().map(endpoint_from).collect())
}
fn endpoint_from(row: &Row) -> Endpoint {
Endpoint {
id: row.get::<String>("id").unwrap_or_default(),
subject: row.get::<String>("subject").unwrap_or_default(),
url: row.get::<String>("url").unwrap_or_default(),
event_types: row.get::<String>("event_types").unwrap_or_default(),
created_at: row.get::<String>("created_at").unwrap_or_default(),
}
}
pub(crate) fn insert_endpoint_statement(
id: &str,
subject: &str,
url: &str,
secret: &str,
event_types: &str,
now: &str,
) -> Statement {
let mut insert = Query::insert();
insert
.into_table(iden(ENDPOINTS))
.columns([
"id",
"subject",
"url",
"secret",
"event_types",
"created_at",
])
.values_panic([
id.to_owned().into(),
subject.to_owned().into(),
url.to_owned().into(),
secret.to_owned().into(),
event_types.to_owned().into(),
now.to_owned().into(),
]);
Statement::render(&insert)
}
pub(crate) async fn delete_endpoint(
db: &dyn Database,
subject: &str,
endpoint_id: &str,
) -> Result<bool, DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(ENDPOINTS))
.and_where(Expr::col(iden("subject")).eq(subject))
.and_where(Expr::col(iden("id")).eq(endpoint_id));
Ok(db.execute(&Statement::render(&delete)).await? == 1)
}
pub(crate) fn enqueue_statement(job: &DeliveryJob, subject: &str, at: &str) -> Statement {
let payload = serde_json::to_string(job).unwrap_or_else(|_| "{}".to_owned());
Outbox::new(OUTBOX).enqueue_statement(
&UlidIdGen.ulid(),
TOPIC_DELIVER,
&payload,
Some(subject),
at,
)
}
pub(crate) async fn record_delivery(
db: &dyn Database,
delivery_id: &str,
job: &DeliveryJob,
attempt: i64,
status_code: Option<i64>,
error: Option<&str>,
at: &str,
) -> Result<(), DbError> {
let mut insert = Query::insert();
insert
.into_table(iden(DELIVERIES))
.columns(DELIVERY_COLUMNS)
.values_panic([
delivery_id.to_owned().into(),
job.endpoint_id.clone().into(),
job.subject.clone().into(),
job.event_id.clone().into(),
job.event_type.clone().into(),
attempt.into(),
status_code.into(),
error.map(str::to_owned).into(),
at.to_owned().into(),
]);
db.execute(&Statement::render(&insert)).await?;
Ok(())
}
pub(crate) async fn deliveries(
db: &dyn Database,
subject: &str,
limit: u64,
) -> Result<Vec<Delivery>, DbError> {
let mut select = Query::select();
select
.columns(DELIVERY_COLUMNS)
.from(iden(DELIVERIES))
.and_where(Expr::col(iden("subject")).eq(subject))
.order_by(iden("at"), Order::Desc)
.order_by(iden("id"), Order::Desc)
.limit(limit);
let rows = db.query(&Statement::render(&select)).await?;
Ok(rows.rows.iter().map(delivery_from).collect())
}
fn delivery_from(row: &Row) -> Delivery {
Delivery {
id: row.get::<String>("id").unwrap_or_default(),
endpoint_id: row.get::<String>("endpoint_id").unwrap_or_default(),
subject: row.get::<String>("subject").unwrap_or_default(),
event_id: row.get::<String>("event_id").unwrap_or_default(),
event_type: row.get::<String>("event_type").unwrap_or_default(),
attempt: row.get::<i64>("attempt").unwrap_or(0),
status_code: row.get::<i64>("status_code"),
error: row.get::<Option<String>>("error").flatten(),
at: row.get::<String>("at").unwrap_or_default(),
}
}
pub(crate) struct GiveUp<'a> {
pub outbox_row_id: &'a str,
pub attempts: i64,
pub reason: DeadLetterReason,
pub last_error: &'a str,
pub status_code: Option<i64>,
pub created_at: &'a str,
pub failed_at: &'a str,
}
pub(crate) async fn dead_letter(
db: &dyn Database,
dead_letter_id: &str,
job: &DeliveryJob,
gave_up: &GiveUp<'_>,
) -> Result<(), DbError> {
let mut insert = Query::insert();
insert
.into_table(iden(DEAD_LETTERS))
.columns(DEAD_LETTER_COLUMNS)
.values_panic([
dead_letter_id.to_owned().into(),
job.endpoint_id.clone().into(),
job.subject.clone().into(),
job.event_id.clone().into(),
job.event_type.clone().into(),
job.data.to_string().into(),
gave_up.attempts.into(),
gave_up.reason.as_str().into(),
gave_up.last_error.to_owned().into(),
gave_up.status_code.into(),
gave_up.created_at.to_owned().into(),
gave_up.failed_at.to_owned().into(),
]);
db.batch_atomic(&[
Statement::render(&insert),
delete_outbox_statement(gave_up.outbox_row_id),
])
.await?;
Ok(())
}
pub(crate) fn delete_outbox_statement(id: &str) -> Statement {
let mut delete = Query::delete();
delete
.from_table(iden(OUTBOX))
.and_where(Expr::col(iden("id")).eq(id));
Statement::render(&delete)
}
pub(crate) async fn dead_letters(
db: &dyn Database,
subject: &str,
limit: u64,
) -> Result<Vec<DeadLetter>, DbError> {
let mut select = Query::select();
select
.columns(DEAD_LETTER_COLUMNS)
.from(iden(DEAD_LETTERS))
.and_where(Expr::col(iden("subject")).eq(subject))
.order_by(iden("failed_at"), Order::Asc)
.limit(limit);
let rows = db.query(&Statement::render(&select)).await?;
Ok(rows.rows.iter().map(dead_letter_from).collect())
}
fn dead_letter_from(row: &Row) -> DeadLetter {
DeadLetter {
id: row.get::<String>("id").unwrap_or_default(),
endpoint_id: row.get::<String>("endpoint_id").unwrap_or_default(),
subject: row.get::<String>("subject").unwrap_or_default(),
event_id: row.get::<String>("event_id").unwrap_or_default(),
event_type: row.get::<String>("event_type").unwrap_or_default(),
data: row.get::<String>("data").unwrap_or_default(),
attempts: row.get::<i64>("attempts").unwrap_or(0),
reason: row.get::<String>("reason").unwrap_or_default(),
last_error: row.get::<String>("last_error").unwrap_or_default(),
status_code: row.get::<i64>("status_code"),
created_at: row.get::<String>("created_at").unwrap_or_default(),
failed_at: row.get::<String>("failed_at").unwrap_or_default(),
}
}
async fn find_dead_letter(
db: &dyn Database,
subject: &str,
dead_letter_id: &str,
) -> Result<Option<DeadLetter>, DbError> {
let mut select = Query::select();
select
.columns(DEAD_LETTER_COLUMNS)
.from(iden(DEAD_LETTERS))
.and_where(Expr::col(iden("subject")).eq(subject))
.and_where(Expr::col(iden("id")).eq(dead_letter_id));
Ok(db
.query(&Statement::render(&select))
.await?
.first()
.map(dead_letter_from))
}
pub(crate) async fn replay(
db: &dyn Database,
subject: &str,
dead_letter_id: &str,
at: &str,
) -> Result<bool, DbError> {
let Some(letter) = find_dead_letter(db, subject, dead_letter_id).await? else {
return Ok(false);
};
if !endpoint_exists(db, subject, &letter.endpoint_id).await? {
return Ok(false);
}
let data: serde_json::Value =
serde_json::from_str(&letter.data).unwrap_or(serde_json::Value::Null);
let job = DeliveryJob {
endpoint_id: letter.endpoint_id,
event_id: letter.event_id,
event_type: letter.event_type,
subject: letter.subject,
created_at: letter.created_at,
data,
};
db.batch_atomic(&[
enqueue_statement(&job, &job.subject, at),
delete_dead_letter_statement(&letter.id),
])
.await?;
Ok(true)
}
pub(crate) fn delete_dead_letter_statement(id: &str) -> Statement {
let mut delete = Query::delete();
delete
.from_table(iden(DEAD_LETTERS))
.and_where(Expr::col(iden("id")).eq(id));
Statement::render(&delete)
}
#[cfg(test)]
pub(crate) fn test_job() -> DeliveryJob {
DeliveryJob {
endpoint_id: "ep".to_owned(),
event_id: "ev".to_owned(),
event_type: "order.paid".to_owned(),
subject: "acct".to_owned(),
created_at: "2026-09-27T00:00:00Z".to_owned(),
data: serde_json::json!({ "amount": 1 }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_migration_carries_the_outbox_ddl_verbatim() {
let sql = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/migrations/sqlite/0001_init.sql"
))
.expect("the migration is in the crate");
assert!(
sql.contains(&Outbox::new(OUTBOX).create_table_sql()),
"webhooks_outbox must match Outbox::create_table_sql"
);
}
#[test]
fn a_job_enqueues_one_row_with_its_subject() {
let job = test_job();
let statement = enqueue_statement(&job, "acct", "2026-09-27T00:00:00Z");
assert!(statement.sql.contains("INSERT INTO"));
assert!(statement.sql.contains("webhooks_outbox"));
assert!(statement.sql.contains("subject"));
}
}