1use cratefield_core::{Database, DbError, IdGen, Outbox, Row, Statement, UlidIdGen};
8use sea_query::{Alias, Expr, Order, Query};
9use serde::{Deserialize, Serialize};
10
11pub(crate) const ENDPOINTS: &str = "webhooks_endpoints";
14pub(crate) const OUTBOX: &str = "webhooks_outbox";
17pub(crate) const DELIVERIES: &str = "webhooks_deliveries";
19pub(crate) const DEAD_LETTERS: &str = "webhooks_dead_letters";
21
22pub const TOPIC_DELIVER: &str = "webhooks.deliver";
24
25fn iden(name: &str) -> Alias {
26 Alias::new(name)
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Endpoint {
36 pub id: String,
37 pub subject: String,
38 pub url: String,
39 pub event_types: String,
40 pub created_at: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Delivery {
46 pub id: String,
47 pub endpoint_id: String,
48 pub subject: String,
49 pub event_id: String,
50 pub event_type: String,
51 pub attempt: i64,
52 pub status_code: Option<i64>,
53 pub error: Option<String>,
54 pub at: String,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(crate) enum DeadLetterReason {
64 AttemptsExhausted,
65 Rejected,
66 Malformed,
67}
68
69impl DeadLetterReason {
70 pub(crate) fn as_str(self) -> &'static str {
72 match self {
73 DeadLetterReason::AttemptsExhausted => "attempts_exhausted",
74 DeadLetterReason::Rejected => "rejected",
75 DeadLetterReason::Malformed => "malformed",
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct DeadLetter {
84 pub id: String,
85 pub endpoint_id: String,
86 pub subject: String,
87 pub event_id: String,
88 pub event_type: String,
89 pub data: String,
90 pub attempts: i64,
91 pub reason: String,
92 pub last_error: String,
93 pub status_code: Option<i64>,
94 pub created_at: String,
97 pub failed_at: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
106pub(crate) struct DeliveryJob {
107 pub endpoint_id: String,
108 pub event_id: String,
109 pub event_type: String,
110 pub subject: String,
111 pub created_at: String,
115 pub data: serde_json::Value,
117}
118
119const ENDPOINT_COLUMNS: [&str; 5] = ["id", "subject", "url", "event_types", "created_at"];
121
122const DELIVERY_COLUMNS: [&str; 9] = [
125 "id",
126 "endpoint_id",
127 "subject",
128 "event_id",
129 "event_type",
130 "attempt",
131 "status_code",
132 "error",
133 "at",
134];
135
136const DEAD_LETTER_COLUMNS: [&str; 12] = [
139 "id",
140 "endpoint_id",
141 "subject",
142 "event_id",
143 "event_type",
144 "data",
145 "attempts",
146 "reason",
147 "last_error",
148 "status_code",
149 "created_at",
150 "failed_at",
151];
152
153pub(crate) struct DeliveryTarget {
158 pub url: String,
159 pub secret: String,
160}
161
162pub(crate) async fn delivery_target(
165 db: &dyn Database,
166 subject: &str,
167 endpoint_id: &str,
168) -> Result<Option<DeliveryTarget>, DbError> {
169 let mut select = Query::select();
170 select
171 .columns(["url", "secret"])
172 .from(iden(ENDPOINTS))
173 .and_where(Expr::col(iden("subject")).eq(subject))
174 .and_where(Expr::col(iden("id")).eq(endpoint_id));
175 Ok(db
176 .query(&Statement::render(&select))
177 .await?
178 .first()
179 .map(|row| DeliveryTarget {
180 url: row.get::<String>("url").unwrap_or_default(),
181 secret: row.get::<String>("secret").unwrap_or_default(),
182 }))
183}
184
185pub(crate) async fn endpoint_exists(
189 db: &dyn Database,
190 subject: &str,
191 endpoint_id: &str,
192) -> Result<bool, DbError> {
193 let mut select = Query::select();
194 select
195 .column(iden("id"))
196 .from(iden(ENDPOINTS))
197 .and_where(Expr::col(iden("subject")).eq(subject))
198 .and_where(Expr::col(iden("id")).eq(endpoint_id));
199 Ok(!db.query(&Statement::render(&select)).await?.rows.is_empty())
200}
201
202pub(crate) async fn endpoints_for_subject(
205 db: &dyn Database,
206 subject: &str,
207) -> Result<Vec<Endpoint>, DbError> {
208 let mut select = Query::select();
209 select
210 .columns(ENDPOINT_COLUMNS)
211 .from(iden(ENDPOINTS))
212 .and_where(Expr::col(iden("subject")).eq(subject))
213 .order_by(iden("created_at"), Order::Asc)
216 .order_by(iden("id"), Order::Asc);
217 let rows = db.query(&Statement::render(&select)).await?;
218 Ok(rows.rows.iter().map(endpoint_from).collect())
219}
220
221fn endpoint_from(row: &Row) -> Endpoint {
222 Endpoint {
223 id: row.get::<String>("id").unwrap_or_default(),
224 subject: row.get::<String>("subject").unwrap_or_default(),
225 url: row.get::<String>("url").unwrap_or_default(),
226 event_types: row.get::<String>("event_types").unwrap_or_default(),
227 created_at: row.get::<String>("created_at").unwrap_or_default(),
228 }
229}
230
231pub(crate) fn insert_endpoint_statement(
234 id: &str,
235 subject: &str,
236 url: &str,
237 secret: &str,
238 event_types: &str,
239 now: &str,
240) -> Statement {
241 let mut insert = Query::insert();
242 insert
243 .into_table(iden(ENDPOINTS))
244 .columns([
245 "id",
246 "subject",
247 "url",
248 "secret",
249 "event_types",
250 "created_at",
251 ])
252 .values_panic([
253 id.to_owned().into(),
254 subject.to_owned().into(),
255 url.to_owned().into(),
256 secret.to_owned().into(),
257 event_types.to_owned().into(),
258 now.to_owned().into(),
259 ]);
260 Statement::render(&insert)
261}
262
263pub(crate) async fn delete_endpoint(
267 db: &dyn Database,
268 subject: &str,
269 endpoint_id: &str,
270) -> Result<bool, DbError> {
271 let mut delete = Query::delete();
272 delete
273 .from_table(iden(ENDPOINTS))
274 .and_where(Expr::col(iden("subject")).eq(subject))
275 .and_where(Expr::col(iden("id")).eq(endpoint_id));
276 Ok(db.execute(&Statement::render(&delete)).await? == 1)
277}
278
279pub(crate) fn enqueue_statement(job: &DeliveryJob, subject: &str, at: &str) -> Statement {
284 let payload = serde_json::to_string(job).unwrap_or_else(|_| "{}".to_owned());
285 Outbox::new(OUTBOX).enqueue_statement(
286 &UlidIdGen.ulid(),
287 TOPIC_DELIVER,
288 &payload,
289 Some(subject),
290 at,
291 )
292}
293
294pub(crate) async fn record_delivery(
301 db: &dyn Database,
302 delivery_id: &str,
303 job: &DeliveryJob,
304 attempt: i64,
305 status_code: Option<i64>,
306 error: Option<&str>,
307 at: &str,
308) -> Result<(), DbError> {
309 let mut insert = Query::insert();
310 insert
311 .into_table(iden(DELIVERIES))
312 .columns(DELIVERY_COLUMNS)
313 .values_panic([
314 delivery_id.to_owned().into(),
315 job.endpoint_id.clone().into(),
316 job.subject.clone().into(),
317 job.event_id.clone().into(),
318 job.event_type.clone().into(),
319 attempt.into(),
320 status_code.into(),
321 error.map(str::to_owned).into(),
322 at.to_owned().into(),
323 ]);
324 db.execute(&Statement::render(&insert)).await?;
325 Ok(())
326}
327
328pub(crate) async fn deliveries(
330 db: &dyn Database,
331 subject: &str,
332 limit: u64,
333) -> Result<Vec<Delivery>, DbError> {
334 let mut select = Query::select();
335 select
336 .columns(DELIVERY_COLUMNS)
337 .from(iden(DELIVERIES))
338 .and_where(Expr::col(iden("subject")).eq(subject))
339 .order_by(iden("at"), Order::Desc)
342 .order_by(iden("id"), Order::Desc)
343 .limit(limit);
344 let rows = db.query(&Statement::render(&select)).await?;
345 Ok(rows.rows.iter().map(delivery_from).collect())
346}
347
348fn delivery_from(row: &Row) -> Delivery {
349 Delivery {
350 id: row.get::<String>("id").unwrap_or_default(),
351 endpoint_id: row.get::<String>("endpoint_id").unwrap_or_default(),
352 subject: row.get::<String>("subject").unwrap_or_default(),
353 event_id: row.get::<String>("event_id").unwrap_or_default(),
354 event_type: row.get::<String>("event_type").unwrap_or_default(),
355 attempt: row.get::<i64>("attempt").unwrap_or(0),
356 status_code: row.get::<i64>("status_code"),
357 error: row.get::<Option<String>>("error").flatten(),
358 at: row.get::<String>("at").unwrap_or_default(),
359 }
360}
361
362pub(crate) struct GiveUp<'a> {
366 pub outbox_row_id: &'a str,
367 pub attempts: i64,
369 pub reason: DeadLetterReason,
370 pub last_error: &'a str,
371 pub status_code: Option<i64>,
372 pub created_at: &'a str,
374 pub failed_at: &'a str,
375}
376
377pub(crate) async fn dead_letter(
381 db: &dyn Database,
382 dead_letter_id: &str,
383 job: &DeliveryJob,
384 gave_up: &GiveUp<'_>,
385) -> Result<(), DbError> {
386 let mut insert = Query::insert();
387 insert
388 .into_table(iden(DEAD_LETTERS))
389 .columns(DEAD_LETTER_COLUMNS)
390 .values_panic([
391 dead_letter_id.to_owned().into(),
392 job.endpoint_id.clone().into(),
393 job.subject.clone().into(),
394 job.event_id.clone().into(),
395 job.event_type.clone().into(),
396 job.data.to_string().into(),
397 gave_up.attempts.into(),
398 gave_up.reason.as_str().into(),
399 gave_up.last_error.to_owned().into(),
400 gave_up.status_code.into(),
401 gave_up.created_at.to_owned().into(),
402 gave_up.failed_at.to_owned().into(),
403 ]);
404 db.batch_atomic(&[
405 Statement::render(&insert),
406 delete_outbox_statement(gave_up.outbox_row_id),
407 ])
408 .await?;
409 Ok(())
410}
411
412pub(crate) fn delete_outbox_statement(id: &str) -> Statement {
413 let mut delete = Query::delete();
414 delete
415 .from_table(iden(OUTBOX))
416 .and_where(Expr::col(iden("id")).eq(id));
417 Statement::render(&delete)
418}
419
420pub(crate) async fn dead_letters(
423 db: &dyn Database,
424 subject: &str,
425 limit: u64,
426) -> Result<Vec<DeadLetter>, DbError> {
427 let mut select = Query::select();
428 select
429 .columns(DEAD_LETTER_COLUMNS)
430 .from(iden(DEAD_LETTERS))
431 .and_where(Expr::col(iden("subject")).eq(subject))
432 .order_by(iden("failed_at"), Order::Asc)
433 .limit(limit);
434 let rows = db.query(&Statement::render(&select)).await?;
435 Ok(rows.rows.iter().map(dead_letter_from).collect())
436}
437
438fn dead_letter_from(row: &Row) -> DeadLetter {
439 DeadLetter {
440 id: row.get::<String>("id").unwrap_or_default(),
441 endpoint_id: row.get::<String>("endpoint_id").unwrap_or_default(),
442 subject: row.get::<String>("subject").unwrap_or_default(),
443 event_id: row.get::<String>("event_id").unwrap_or_default(),
444 event_type: row.get::<String>("event_type").unwrap_or_default(),
445 data: row.get::<String>("data").unwrap_or_default(),
446 attempts: row.get::<i64>("attempts").unwrap_or(0),
447 reason: row.get::<String>("reason").unwrap_or_default(),
448 last_error: row.get::<String>("last_error").unwrap_or_default(),
449 status_code: row.get::<i64>("status_code"),
450 created_at: row.get::<String>("created_at").unwrap_or_default(),
451 failed_at: row.get::<String>("failed_at").unwrap_or_default(),
452 }
453}
454
455async fn find_dead_letter(
457 db: &dyn Database,
458 subject: &str,
459 dead_letter_id: &str,
460) -> Result<Option<DeadLetter>, DbError> {
461 let mut select = Query::select();
462 select
463 .columns(DEAD_LETTER_COLUMNS)
464 .from(iden(DEAD_LETTERS))
465 .and_where(Expr::col(iden("subject")).eq(subject))
466 .and_where(Expr::col(iden("id")).eq(dead_letter_id));
467 Ok(db
468 .query(&Statement::render(&select))
469 .await?
470 .first()
471 .map(dead_letter_from))
472}
473
474pub(crate) async fn replay(
481 db: &dyn Database,
482 subject: &str,
483 dead_letter_id: &str,
484 at: &str,
485) -> Result<bool, DbError> {
486 let Some(letter) = find_dead_letter(db, subject, dead_letter_id).await? else {
487 return Ok(false);
488 };
489 if !endpoint_exists(db, subject, &letter.endpoint_id).await? {
490 return Ok(false);
491 }
492 let data: serde_json::Value =
493 serde_json::from_str(&letter.data).unwrap_or(serde_json::Value::Null);
494 let job = DeliveryJob {
495 endpoint_id: letter.endpoint_id,
496 event_id: letter.event_id,
497 event_type: letter.event_type,
498 subject: letter.subject,
499 created_at: letter.created_at,
500 data,
501 };
502 db.batch_atomic(&[
503 enqueue_statement(&job, &job.subject, at),
504 delete_dead_letter_statement(&letter.id),
505 ])
506 .await?;
507 Ok(true)
508}
509
510pub(crate) fn delete_dead_letter_statement(id: &str) -> Statement {
511 let mut delete = Query::delete();
512 delete
513 .from_table(iden(DEAD_LETTERS))
514 .and_where(Expr::col(iden("id")).eq(id));
515 Statement::render(&delete)
516}
517
518#[cfg(test)]
521pub(crate) fn test_job() -> DeliveryJob {
522 DeliveryJob {
523 endpoint_id: "ep".to_owned(),
524 event_id: "ev".to_owned(),
525 event_type: "order.paid".to_owned(),
526 subject: "acct".to_owned(),
527 created_at: "2026-09-27T00:00:00Z".to_owned(),
528 data: serde_json::json!({ "amount": 1 }),
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
539 fn the_migration_carries_the_outbox_ddl_verbatim() {
540 let sql = std::fs::read_to_string(concat!(
541 env!("CARGO_MANIFEST_DIR"),
542 "/migrations/sqlite/0001_init.sql"
543 ))
544 .expect("the migration is in the crate");
545 assert!(
546 sql.contains(&Outbox::new(OUTBOX).create_table_sql()),
547 "webhooks_outbox must match Outbox::create_table_sql"
548 );
549 }
550
551 #[test]
552 fn a_job_enqueues_one_row_with_its_subject() {
553 let job = test_job();
554 let statement = enqueue_statement(&job, "acct", "2026-09-27T00:00:00Z");
555 assert!(statement.sql.contains("INSERT INTO"));
556 assert!(statement.sql.contains("webhooks_outbox"));
557 assert!(statement.sql.contains("subject"));
558 }
559}