Skip to main content

hexeract_outbox_sql/
sqlite.rs

1//! SQLite backend for the Hexeract outbox.
2//!
3//! # Concurrency
4//!
5//! SQLite has no `FOR UPDATE SKIP LOCKED`, so this backend assumes a
6//! **single [`OutboxWorker`](hexeract_outbox::OutboxWorker) per database**. Running several workers against
7//! the same SQLite database can dispatch an envelope more than once, because
8//! concurrent pollers may read the same pending rows before either marks them
9//! delivered. For competing-consumers fan-out across many workers, use the
10//! PostgreSQL or MySQL backend instead. Configuring `busy_timeout` on the pool
11//! is recommended so writes wait rather than fail under contention.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17use async_trait::async_trait;
18use hexeract_outbox::ErasedHandler;
19use hexeract_outbox::Event;
20use hexeract_outbox::Handler;
21use hexeract_outbox::IdempotentOutboxEnqueue;
22use hexeract_outbox::OutboxEnvelope;
23use hexeract_outbox::OutboxError;
24use hexeract_outbox::OutboxPublisher;
25use hexeract_outbox::OutboxStore;
26use hexeract_outbox::OutboxWorker;
27use hexeract_outbox::OutboxWorkerConfig;
28use hexeract_outbox::TypedHandler;
29use sqlx::Acquire;
30use sqlx::Row;
31use sqlx::Sqlite;
32use sqlx::SqlitePool;
33use sqlx::Transaction;
34use sqlx::pool::PoolConnection;
35use uuid::Uuid;
36
37use crate::DEFAULT_TABLE_NAME;
38use crate::dialect::Dialect;
39use crate::envelope::assemble_envelope;
40use crate::envelope::parse_sqlite_utc;
41use crate::validate::validate_event_type;
42use crate::validate::validate_table_name;
43
44const DIALECT: Dialect = Dialect::Sqlite;
45
46/// Maximum interval in seconds that can be safely passed to SQLite's strftime
47/// modifier (`"+N seconds"`).
48///
49/// SQLite's strftime modifier must parse to a finite value. Durations near
50/// [`Duration::MAX`] produce `"+inf seconds"` via [`f64::INFINITY`], which
51/// SQLite ignores silently, leaving `next_retry_at` as `NULL`. Capping at
52/// this value (roughly 292 years) keeps the result well within SQLite's
53/// datetime range while being far beyond any practical retry interval.
54const MAX_SQLITE_INTERVAL_SECS: u64 = 9_223_372_036; // i64::MAX seconds
55
56/// Render a backoff/lease [`Duration`] as a SQLite `strftime` modifier, e.g.
57/// `"+1.500 seconds"`, so `next_retry_at` is computed from the database clock.
58///
59/// The duration is capped at [`MAX_SQLITE_INTERVAL_SECS`] before conversion
60/// so that pathologically large values do not produce an `"+inf seconds"`
61/// modifier that SQLite would silently ignore.
62fn sqlite_seconds_modifier(d: Duration) -> String {
63    let capped = d.min(Duration::from_secs(MAX_SQLITE_INTERVAL_SECS));
64    format!("+{:.3} seconds", capped.as_secs_f64())
65}
66
67fn database_error(error: impl std::error::Error + Send + Sync + 'static) -> OutboxError {
68    OutboxError::Database(Box::new(error))
69}
70
71fn pool_error(error: sqlx::Error) -> OutboxError {
72    if matches!(error, sqlx::Error::PoolTimedOut) {
73        OutboxError::PoolTimeout
74    } else {
75        OutboxError::Database(Box::new(error))
76    }
77}
78
79/// Decode one polled row into an [`OutboxEnvelope`].
80///
81/// Kept separate from the poll loop so a decode failure (notably a timestamp
82/// that does not match either accepted SQLite layout) can be isolated to the
83/// offending row (logged and skipped) instead of aborting the whole batch.
84fn decode_sqlite_row(row: &sqlx::sqlite::SqliteRow) -> Result<OutboxEnvelope, OutboxError> {
85    let event_id: Uuid = row.try_get("event_id").map_err(database_error)?;
86    let event_type: String = row.try_get("event_type").map_err(database_error)?;
87    let payload: serde_json::Value = row.try_get("payload").map_err(database_error)?;
88    let subject_id: Option<Uuid> = row.try_get("subject_id").map_err(database_error)?;
89    let created_at: String = row.try_get("created_at").map_err(database_error)?;
90    let attempts: i64 = row.try_get("attempts").map_err(database_error)?;
91    let last_error: Option<String> = row.try_get("last_error").map_err(database_error)?;
92    let next_retry_at: Option<String> = row.try_get("next_retry_at").map_err(database_error)?;
93
94    let payload = serde_json::to_vec(&payload)?;
95    let next_retry_at = next_retry_at.as_deref().map(parse_sqlite_utc).transpose()?;
96
97    Ok(assemble_envelope(
98        event_id,
99        event_type,
100        payload,
101        subject_id,
102        parse_sqlite_utc(&created_at)?,
103        u32::try_from(attempts.max(0)).unwrap_or(u32::MAX),
104        last_error,
105        next_retry_at,
106    ))
107}
108
109#[derive(Debug, Clone)]
110struct DeadLetterSql {
111    insert_sql: Arc<str>,
112    delete_sql: Arc<str>,
113}
114
115/// Apply the canonical SQLite outbox schema to the target database.
116///
117/// **Intended for POCs, integration tests and local development.**
118/// Production deployments should run their own migration tooling against the
119/// SQL rendered by [`Dialect::schema_ddl`].
120///
121/// # Errors
122///
123/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
124/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
125pub async fn ensure_schema(pool: &SqlitePool, table_name: &str) -> Result<(), OutboxError> {
126    let ddl = DIALECT.schema_ddl(table_name)?;
127    sqlx::raw_sql(&ddl)
128        .execute(pool)
129        .await
130        .map_err(database_error)?;
131    Ok(())
132}
133
134/// SQLite implementation of [`OutboxStore`] backed by `sqlx::SqlitePool`.
135///
136/// See the [module documentation](self) for the single-worker concurrency model.
137/// Cheap to clone (the pool and the cached SQL strings are reference-counted).
138#[derive(Debug, Clone)]
139pub struct SqliteOutboxStore {
140    pool: SqlitePool,
141    table_name: Arc<str>,
142    poll_sql: Arc<str>,
143    mark_delivered_sql: Arc<str>,
144    mark_failed_sql: Arc<str>,
145    dead_letter: Option<Arc<DeadLetterSql>>,
146}
147
148impl SqliteOutboxStore {
149    /// Build a store for the given pool and table.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
154    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
155    pub fn new(pool: SqlitePool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
156        let table_name = table_name.into();
157        validate_table_name(&table_name)?;
158        let poll_sql = DIALECT.poll_sql(&table_name);
159        let mark_delivered_sql = DIALECT.mark_delivered_sql(&table_name);
160        let mark_failed_sql = DIALECT.mark_failed_sql(&table_name);
161        Ok(Self {
162            pool,
163            table_name: Arc::from(table_name),
164            poll_sql: Arc::from(poll_sql),
165            mark_delivered_sql: Arc::from(mark_delivered_sql),
166            mark_failed_sql: Arc::from(mark_failed_sql),
167            dead_letter: None,
168        })
169    }
170
171    /// Underlying pool.
172    #[must_use]
173    pub fn pool(&self) -> &SqlitePool {
174        &self.pool
175    }
176
177    /// Configured table name.
178    #[must_use]
179    pub fn table_name(&self) -> &str {
180        &self.table_name
181    }
182
183    /// Activate dead-letter persistence for poison messages.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`OutboxError::Internal`] if `dlq_table` is not a valid identifier.
188    pub fn with_dead_letter(mut self, dlq_table: impl Into<String>) -> Result<Self, OutboxError> {
189        let dlq = dlq_table.into();
190        validate_table_name(&dlq)?;
191        let insert_sql = DIALECT.insert_dead_letter_sql(&self.table_name, &dlq);
192        let delete_sql = DIALECT.delete_from_main_sql(&self.table_name);
193        self.dead_letter = Some(Arc::new(DeadLetterSql {
194            insert_sql: Arc::from(insert_sql),
195            delete_sql: Arc::from(delete_sql),
196        }));
197        Ok(self)
198    }
199}
200
201#[async_trait]
202impl OutboxStore for SqliteOutboxStore {
203    type Client = PoolConnection<Sqlite>;
204    type Tx<'tx> = Transaction<'tx, Sqlite>;
205
206    async fn acquire(&self) -> Result<Self::Client, OutboxError> {
207        self.pool.acquire().await.map_err(pool_error)
208    }
209
210    async fn begin<'a>(&self, client: &'a mut Self::Client) -> Result<Self::Tx<'a>, OutboxError> {
211        client.begin().await.map_err(database_error)
212    }
213
214    async fn poll<'a>(
215        &self,
216        tx: &mut Self::Tx<'a>,
217        batch_size: usize,
218        max_attempts: u32,
219    ) -> Result<Vec<OutboxEnvelope>, OutboxError> {
220        let limit = i64::try_from(batch_size).unwrap_or(i64::MAX);
221        let max = i64::from(max_attempts);
222        let rows = sqlx::query(&self.poll_sql)
223            .bind(max)
224            .bind(limit)
225            .fetch_all(&mut **tx)
226            .await
227            .map_err(database_error)?;
228
229        let mut envelopes = Vec::with_capacity(rows.len());
230        for row in rows {
231            // A single undecodable row (e.g. an externally written timestamp in
232            // an unexpected layout) must not abort the whole poll: that
233            // head-of-line poisons the queue forever (#214). Log it and skip so
234            // the rest of the batch keeps draining.
235            match decode_sqlite_row(&row) {
236                Ok(envelope) => envelopes.push(envelope),
237                Err(error) => {
238                    let event_id = row.try_get::<Uuid, _>("event_id").ok();
239                    tracing::error!(
240                        ?event_id,
241                        error = %error,
242                        "skipping undecodable outbox row; the rest of the batch continues"
243                    );
244                }
245            }
246        }
247        Ok(envelopes)
248    }
249
250    async fn mark_delivered<'a>(
251        &self,
252        tx: &mut Self::Tx<'a>,
253        event_id: Uuid,
254    ) -> Result<(), OutboxError> {
255        sqlx::query(&self.mark_delivered_sql)
256            .bind(event_id)
257            .execute(&mut **tx)
258            .await
259            .map_err(database_error)?;
260        Ok(())
261    }
262
263    async fn mark_failed<'a>(
264        &self,
265        tx: &mut Self::Tx<'a>,
266        event_id: Uuid,
267        error: &str,
268        retry_in: Duration,
269    ) -> Result<(), OutboxError> {
270        // next_retry_at is computed as strftime('now', ?modifier) against the
271        // DB clock (#230); bind the backoff as a strftime seconds modifier.
272        sqlx::query(&self.mark_failed_sql)
273            .bind(error)
274            .bind(sqlite_seconds_modifier(retry_in))
275            .bind(event_id)
276            .execute(&mut **tx)
277            .await
278            .map_err(database_error)?;
279        Ok(())
280    }
281
282    async fn commit<'a>(&self, tx: Self::Tx<'a>) -> Result<(), OutboxError> {
283        tx.commit().await.map_err(database_error)
284    }
285
286    async fn mark_dead_lettered<'a>(
287        &self,
288        tx: &mut Self::Tx<'a>,
289        event_id: Uuid,
290        _error: &str,
291    ) -> Result<(), OutboxError> {
292        let Some(dlq) = &self.dead_letter else {
293            return Ok(());
294        };
295        sqlx::query(&dlq.insert_sql)
296            .bind(event_id)
297            .execute(&mut **tx)
298            .await
299            .map_err(database_error)?;
300        sqlx::query(&dlq.delete_sql)
301            .bind(event_id)
302            .execute(&mut **tx)
303            .await
304            .map_err(database_error)?;
305        Ok(())
306    }
307
308    /// Set the soft lease and consume one retry slot on the claimed batch.
309    ///
310    /// SQLite has no `FOR UPDATE SKIP LOCKED`, so this does **not** provide a
311    /// competing-consumer claim: the store remains single-writer (see the
312    /// [module documentation](self)). The override exists so that `attempts`
313    /// is incremented at claim time on SQLite too. Without it, a worker that
314    /// crashed between claim and acknowledgement would never advance
315    /// `attempts` and would redeliver a poison row forever (#213).
316    async fn claim<'a>(
317        &self,
318        tx: &mut Self::Tx<'a>,
319        event_ids: &[Uuid],
320        lease_for: Duration,
321    ) -> Result<(), OutboxError> {
322        if event_ids.is_empty() {
323            return Ok(());
324        }
325        // Lease anchored to the DB clock via strftime('now', ?modifier) (#230).
326        let sql = DIALECT.claim_sql(&self.table_name, event_ids.len());
327        let mut query = sqlx::query(&sql).bind(sqlite_seconds_modifier(lease_for));
328        for id in event_ids {
329            query = query.bind(*id);
330        }
331        query.execute(&mut **tx).await.map_err(database_error)?;
332        Ok(())
333    }
334}
335
336/// SQLite implementation of [`OutboxPublisher`] backed by `sqlx::SqlitePool`.
337///
338/// Cheap to clone (the pool and the cached insert statement are reference-counted).
339#[derive(Debug, Clone)]
340pub struct SqliteOutboxPublisher {
341    pool: SqlitePool,
342    table_name: Arc<str>,
343    insert_sql: Arc<str>,
344    idempotent_insert_sql: Arc<str>,
345}
346
347impl SqliteOutboxPublisher {
348    /// Create a new publisher for the given pool and table.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
353    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
354    pub fn new(pool: SqlitePool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
355        let table_name = table_name.into();
356        validate_table_name(&table_name)?;
357        let insert_sql = DIALECT.insert_sql(&table_name);
358        let idempotent_insert_sql = DIALECT.insert_idempotent_sql(&table_name);
359        Ok(Self {
360            pool,
361            table_name: Arc::from(table_name),
362            insert_sql: Arc::from(insert_sql),
363            idempotent_insert_sql: Arc::from(idempotent_insert_sql),
364        })
365    }
366
367    /// Underlying pool, exposed for callers that open their own transactions.
368    #[must_use]
369    pub fn pool(&self) -> &SqlitePool {
370        &self.pool
371    }
372
373    /// Configured table name.
374    #[must_use]
375    pub fn table_name(&self) -> &str {
376        &self.table_name
377    }
378}
379
380impl OutboxPublisher for SqliteOutboxPublisher {
381    type Tx<'tx> = Transaction<'tx, Sqlite>;
382
383    async fn publish_in_tx<E: Event>(
384        &self,
385        tx: &mut Self::Tx<'_>,
386        event: &E,
387    ) -> Result<Uuid, OutboxError> {
388        validate_event_type(E::EVENT_TYPE)?;
389        let event_id = Uuid::now_v7();
390        let payload = serde_json::to_value(event)?;
391        sqlx::query(&self.insert_sql)
392            .bind(event_id)
393            .bind(E::EVENT_TYPE)
394            .bind(payload)
395            .bind(Option::<Uuid>::None)
396            .execute(&mut **tx)
397            .await
398            .map_err(database_error)?;
399        Ok(event_id)
400    }
401
402    async fn publish_in_tx_with_subject<E: Event>(
403        &self,
404        tx: &mut Self::Tx<'_>,
405        subject_id: Uuid,
406        event: &E,
407    ) -> Result<Uuid, OutboxError> {
408        validate_event_type(E::EVENT_TYPE)?;
409        let event_id = Uuid::now_v7();
410        let payload = serde_json::to_value(event)?;
411        sqlx::query(&self.insert_sql)
412            .bind(event_id)
413            .bind(E::EVENT_TYPE)
414            .bind(payload)
415            .bind(Some(subject_id))
416            .execute(&mut **tx)
417            .await
418            .map_err(database_error)?;
419        Ok(event_id)
420    }
421
422    async fn publish<E: Event>(&self, event: &E) -> Result<Uuid, OutboxError> {
423        let mut tx = self.pool.begin().await.map_err(database_error)?;
424        let event_id = self.publish_in_tx(&mut tx, event).await?;
425        tx.commit().await.map_err(database_error)?;
426        Ok(event_id)
427    }
428}
429
430impl IdempotentOutboxEnqueue for SqliteOutboxPublisher {
431    async fn enqueue_idempotent(
432        &self,
433        event_id: Uuid,
434        event_type: &str,
435        payload: &[u8],
436    ) -> Result<bool, OutboxError> {
437        validate_event_type(event_type)?;
438        let payload_value = serde_json::from_slice::<serde_json::Value>(payload)?;
439        let mut tx = self.pool.begin().await.map_err(database_error)?;
440        let result = sqlx::query(&self.idempotent_insert_sql)
441            .bind(event_id)
442            .bind(event_type)
443            .bind(payload_value)
444            .execute(&mut *tx)
445            .await
446            .map_err(database_error)?;
447        tx.commit().await.map_err(database_error)?;
448        Ok(result.rows_affected() > 0)
449    }
450}
451
452/// Fluent builder for an [`OutboxWorker`] backed by [`SqliteOutboxStore`].
453///
454/// See the [module documentation](self) for the single-worker concurrency model.
455///
456/// # Pool sizing and acquire timeout
457///
458/// SQLite uses a single-writer model. A pool size of 1–2 connections is
459/// typical: the worker holds one connection per poll cycle while publishers
460/// take the other. To prevent a stuck writer from blocking `acquire()`
461/// indefinitely, set an acquire timeout on the pool:
462///
463/// ```rust,ignore
464/// use sqlx::sqlite::SqlitePoolOptions;
465/// use std::time::Duration;
466///
467/// let pool = SqlitePoolOptions::new()
468///     .max_connections(2)
469///     // surface PoolTimeout instead of blocking indefinitely
470///     .acquire_timeout(Duration::from_secs(5))
471///     .connect("sqlite:outbox.db")
472///     .await?;
473///
474/// let worker = SqliteOutboxWorkerBuilder::new(pool).build()?;
475/// ```
476///
477/// When `acquire_timeout` expires, [`OutboxStore::acquire`] returns
478/// [`OutboxError::PoolTimeout`] instead of hanging. The worker logs the
479/// error and retries after [`OutboxWorkerConfig::poll_interval`].
480///
481/// [`OutboxError::PoolTimeout`]: hexeract_outbox::OutboxError::PoolTimeout
482/// [`OutboxWorkerConfig::poll_interval`]: hexeract_outbox::OutboxWorkerConfig::poll_interval
483pub struct SqliteOutboxWorkerBuilder {
484    pool: SqlitePool,
485    table_name: String,
486    dead_letter_table: Option<String>,
487    handlers: HashMap<&'static str, Arc<dyn ErasedHandler>>,
488    config: OutboxWorkerConfig,
489}
490
491impl SqliteOutboxWorkerBuilder {
492    /// Start a new builder for the given pool.
493    #[must_use]
494    pub fn new(pool: SqlitePool) -> Self {
495        Self {
496            pool,
497            table_name: DEFAULT_TABLE_NAME.to_owned(),
498            dead_letter_table: None,
499            handlers: HashMap::new(),
500            config: OutboxWorkerConfig::default(),
501        }
502    }
503
504    /// Override the outbox table name (default `"audit_outbox"`).
505    #[must_use]
506    pub fn table_name(mut self, name: impl Into<String>) -> Self {
507        self.table_name = name.into();
508        self
509    }
510
511    /// Enable dead-letter persistence for poison messages.
512    #[must_use]
513    pub fn dead_letter_table(mut self, name: impl Into<String>) -> Self {
514        self.dead_letter_table = Some(name.into());
515        self
516    }
517
518    /// Register a typed handler for the event type `E`.
519    ///
520    /// Registering twice for the same event type silently replaces the
521    /// previous handler.
522    #[must_use]
523    pub fn register_handler<E, H>(mut self, handler: H) -> Self
524    where
525        E: Event,
526        H: Handler<E>,
527    {
528        let typed = TypedHandler::<E, H>::new(handler);
529        let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
530        self.handlers.insert(E::EVENT_TYPE, erased);
531        self
532    }
533
534    /// Register a handler already shared behind an `Arc`.
535    #[must_use]
536    pub fn shared_handler<E, H>(mut self, handler: Arc<H>) -> Self
537    where
538        E: Event,
539        H: Handler<E>,
540    {
541        let typed = TypedHandler::<E, H>::shared(handler);
542        let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
543        self.handlers.insert(E::EVENT_TYPE, erased);
544        self
545    }
546
547    /// Override the poll interval (default 100 ms).
548    #[must_use]
549    pub fn poll_interval(mut self, d: Duration) -> Self {
550        self.config.poll_interval = d;
551        self
552    }
553
554    /// Override the batch size per poll (default 10).
555    #[must_use]
556    pub fn batch_size(mut self, n: usize) -> Self {
557        self.config.batch_size = n;
558        self
559    }
560
561    /// Override the maximum number of attempts per envelope (default 5).
562    #[must_use]
563    pub fn max_attempts(mut self, n: u32) -> Self {
564        self.config.max_attempts = n;
565        self
566    }
567
568    /// Override the base delay for exponential backoff (default 1 s).
569    #[must_use]
570    pub fn retry_base_delay(mut self, d: Duration) -> Self {
571        self.config.retry_base_delay = d;
572        self
573    }
574
575    /// Override the maximum backoff delay (default 5 min).
576    #[must_use]
577    pub fn retry_max_delay(mut self, d: Duration) -> Self {
578        self.config.retry_max_delay = d;
579        self
580    }
581
582    /// Enable or disable full jitter on the backoff delay (default `true`).
583    #[must_use]
584    pub fn jitter(mut self, enabled: bool) -> Self {
585        self.config.jitter = enabled;
586        self
587    }
588
589    /// Override the soft-lease duration for claimed envelopes (default 30 s).
590    ///
591    /// SQLite has no `FOR UPDATE SKIP LOCKED` and therefore no
592    /// competing-consumer claim, so this store is meant for a **single
593    /// worker** (see the [module documentation](self)); running several workers
594    /// against one database can still double-dispatch. The lease is recorded on
595    /// claim alongside the attempt increment, but with a single writer it only
596    /// affects when a crashed-and-restarted worker re-picks an in-flight row.
597    #[must_use]
598    pub fn dispatch_timeout(mut self, d: Duration) -> Self {
599        self.config.dispatch_timeout = d;
600        self
601    }
602
603    /// Consume the builder and produce an [`OutboxWorker`] ready to spawn.
604    ///
605    /// # Errors
606    ///
607    /// Returns [`OutboxError::Internal`] if the configured `table_name`
608    /// is not a valid identifier.
609    pub fn build(self) -> Result<OutboxWorker<SqliteOutboxStore>, OutboxError> {
610        let mut store = SqliteOutboxStore::new(self.pool, self.table_name)?;
611        if let Some(dlq) = self.dead_letter_table {
612            store = store.with_dead_letter(dlq)?;
613        }
614        Ok(OutboxWorker::new(store, self.handlers, self.config))
615    }
616}
617
618/// Apply the dead-letter schema to the target SQLite database.
619///
620/// **Intended for POCs, integration tests and local development.**
621///
622/// # Errors
623///
624/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
625/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
626pub async fn ensure_dead_letter_schema(
627    pool: &SqlitePool,
628    table_name: &str,
629) -> Result<(), OutboxError> {
630    let ddl = DIALECT.dead_letter_schema_ddl(table_name)?;
631    sqlx::raw_sql(&ddl)
632        .execute(pool)
633        .await
634        .map_err(database_error)?;
635    Ok(())
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use hexeract_core::HandlerContext;
642    use serde::Deserialize;
643    use serde::Serialize;
644
645    fn lazy_pool() -> SqlitePool {
646        SqlitePool::connect_lazy("sqlite::memory:").expect("lazy pool must build from a valid URL")
647    }
648
649    #[derive(Debug, Serialize, Deserialize)]
650    struct UserRegistered {
651        user_id: Uuid,
652    }
653
654    impl Event for UserRegistered {
655        const EVENT_TYPE: &'static str = "users.registered";
656    }
657
658    #[derive(Debug, Serialize, Deserialize)]
659    struct OrderPlaced {
660        order_id: Uuid,
661    }
662
663    impl Event for OrderPlaced {
664        const EVENT_TYPE: &'static str = "orders.placed";
665    }
666
667    struct NoopHandler;
668
669    impl Handler<UserRegistered> for NoopHandler {
670        type Error = OutboxError;
671        async fn handle(
672            &self,
673            _event: UserRegistered,
674            _ctx: &HandlerContext,
675        ) -> Result<(), Self::Error> {
676            Ok(())
677        }
678    }
679
680    impl Handler<OrderPlaced> for NoopHandler {
681        type Error = OutboxError;
682        async fn handle(
683            &self,
684            _event: OrderPlaced,
685            _ctx: &HandlerContext,
686        ) -> Result<(), Self::Error> {
687            Ok(())
688        }
689    }
690
691    #[test]
692    fn pool_error_maps_pool_timed_out_to_pool_timeout_variant() {
693        let err = pool_error(sqlx::Error::PoolTimedOut);
694        assert!(
695            matches!(err, OutboxError::PoolTimeout),
696            "PoolTimedOut must map to OutboxError::PoolTimeout, got {err:?}"
697        );
698    }
699
700    #[test]
701    fn pool_error_wraps_other_errors_as_database_error() {
702        let err = pool_error(sqlx::Error::RowNotFound);
703        assert!(
704            matches!(err, OutboxError::Database(_)),
705            "non-timeout errors must map to OutboxError::Database, got {err:?}"
706        );
707    }
708
709    #[tokio::test]
710    async fn store_new_rejects_invalid_table_name() {
711        let err = SqliteOutboxStore::new(lazy_pool(), "bad name; DROP").unwrap_err();
712        assert!(matches!(err, OutboxError::Internal(_)));
713    }
714
715    #[tokio::test]
716    async fn store_new_caches_sqlite_sql_without_skip_locked() {
717        let store = SqliteOutboxStore::new(lazy_pool(), "audit_outbox").unwrap();
718        assert_eq!(store.table_name(), "audit_outbox");
719        assert!(store.poll_sql.contains("FROM \"audit_outbox\""));
720        assert!(!store.poll_sql.contains("FOR UPDATE SKIP LOCKED"));
721        assert!(store.poll_sql.contains("strftime"));
722        // The attempt increment lives in claim_sql now (see #213), not in
723        // mark_failed.
724        assert!(!store.mark_failed_sql.contains("attempts = attempts + 1"));
725    }
726
727    #[tokio::test]
728    async fn publisher_new_caches_insert_sql_with_question_marks() {
729        let publisher = SqliteOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
730        assert_eq!(publisher.table_name(), "audit_outbox");
731        assert!(
732            publisher
733                .insert_sql
734                .contains("INSERT INTO \"audit_outbox\"")
735        );
736        assert!(publisher.insert_sql.contains("?, ?, ?, ?"));
737    }
738
739    #[test]
740    fn sqlite_seconds_modifier_caps_huge_duration() {
741        // Duration::MAX produces inf via as_secs_f64(); capping prevents an
742        // "+inf seconds" modifier that SQLite would silently ignore (#240).
743        let modifier = sqlite_seconds_modifier(Duration::MAX);
744        assert!(
745            !modifier.contains("inf"),
746            "Duration::MAX must not produce an inf modifier, got: {modifier}"
747        );
748        assert!(modifier.starts_with('+'), "modifier must start with '+'");
749        assert!(
750            modifier.ends_with(" seconds"),
751            "modifier must end with ' seconds'"
752        );
753    }
754
755    #[test]
756    fn sqlite_seconds_modifier_preserves_ordinary_values() {
757        let modifier = sqlite_seconds_modifier(Duration::from_millis(1_500));
758        assert_eq!(modifier, "+1.500 seconds");
759    }
760
761    #[tokio::test]
762    async fn builder_register_handler_records_event_types() {
763        let builder = SqliteOutboxWorkerBuilder::new(lazy_pool())
764            .register_handler::<UserRegistered, _>(NoopHandler)
765            .register_handler::<OrderPlaced, _>(NoopHandler);
766        assert_eq!(builder.handlers.len(), 2);
767        assert!(builder.handlers.contains_key("users.registered"));
768        assert!(builder.handlers.contains_key("orders.placed"));
769    }
770
771    #[tokio::test]
772    async fn builder_build_rejects_invalid_table_name() {
773        let result = SqliteOutboxWorkerBuilder::new(lazy_pool())
774            .table_name("bad name; DROP TABLE")
775            .build();
776        assert!(matches!(result, Err(OutboxError::Internal(_))));
777    }
778
779    #[tokio::test]
780    async fn builder_build_with_default_table_name_succeeds() {
781        let worker = SqliteOutboxWorkerBuilder::new(lazy_pool()).build();
782        assert!(worker.is_ok());
783    }
784}