Skip to main content

arcature_data/
transaction.rs

1//! Typed transactions over the SeaORM and SQLx paths, with explicit ownership
2//! and no hidden nesting/savepoint magic (PROGRAM.md AP2.1-6).
3//!
4//! [`Transaction::orm`] delegates to SeaORM's `TransactionTrait::transaction`
5//! over the explicit `&Db`. [`Transaction::sqlx`] begins a raw SQLx transaction
6//! over `db.sqlx()`. Arcature adds **no** additional savepoint, auto-nesting, or
7//! silent rollback semantics beyond what the underlying library does:
8//!
9//! - SeaORM's `transaction` runs the closure against a transactional connection
10//!   and **rolls back when the closure returns `Err`** — that is SeaORM's
11//!   documented behavior, not Arcature magic.
12//! - SQLx rolls back a `Transaction` that is dropped without `commit`; this
13//!   closure wrapper commits on `Ok` and returns the error (leaving rollback to
14//!   SQLx) on `Err`.
15//!
16//! Both take the `&Db` by reference. There is no thread-local, task-local, or
17//! request-global transaction context (AGENTS.md §20).
18
19use std::future::Future;
20
21use arcature_db::Db;
22use arcature_db::sea_orm;
23use arcature_db::sea_orm::TransactionTrait;
24use arcature_db::sqlx;
25
26use crate::error::DataError;
27
28/// Typed transactions over the explicit `&Db` — SeaORM and SQLx paths.
29///
30/// This is a namespace for the two transaction entry points; it holds no state.
31/// Construct nothing — call [`Transaction::orm`] or [`Transaction::sqlx`] with
32/// an explicit `&Db`.
33pub struct Transaction;
34
35impl Transaction {
36    /// Run a closure inside a SeaORM transaction over `db.orm()`.
37    ///
38    /// The closure receives a `&sea_orm::DatabaseTransaction` to pass to
39    /// SeaORM operations (e.g. `active.insert(txn)`). SeaORM commits when the
40    /// closure returns `Ok` and rolls back when it returns `Err` — that is
41    /// SeaORM's transaction semantics, surfaced without additional magic.
42    ///
43    /// ```ignore
44    /// use arcature_data::Transaction;
45    /// # async fn run(db: &arcature_db::Db) -> Result<(), arcature_data::DataError> {
46    /// Transaction::orm(db, |txn| Box::pin(async move {
47    ///     // SeaORM operations against `txn`…
48    ///     Ok::<_, arcature_db::sea_orm::DbErr>(())
49    /// }))
50    /// .await?;
51    /// # Ok(())
52    /// # }
53    /// ```
54    ///
55    /// # Errors
56    ///
57    /// Returns [`DataError::Database`] if the transaction fails (the closure
58    /// returned `Err`, or the commit/rollback itself errored).
59    pub async fn orm<F, T>(db: &Db, f: F) -> Result<T, DataError>
60    where
61        F: for<'c> FnOnce(
62                &'c sea_orm::DatabaseTransaction,
63            ) -> std::pin::Pin<
64                Box<dyn Future<Output = Result<T, sea_orm::DbErr>> + Send + 'c>,
65            > + Send,
66        T: Send,
67    {
68        let result = db.orm().transaction(f).await;
69        result.map_err(|error| {
70            // Both variants of `TransactionError<DbErr>` carry a `DbErr`:
71            // `Connection(DbErr)` (BEGIN/COMMIT/ROLLBACK failure) and
72            // `Transaction(DbErr)` (the closure returned `Err`).
73            let db_err = match error {
74                sea_orm::TransactionError::Connection(err) => err,
75                sea_orm::TransactionError::Transaction(err) => err,
76            };
77            DataError::from(db_err)
78        })
79    }
80
81    /// Run a closure inside a raw SQLx transaction over `db.sqlx()`.
82    ///
83    /// The closure receives a `&mut sqlx::Transaction` for raw SQLx operations
84    /// (`sqlx::query!(...).execute(&mut **txn)`). The wrapper commits when the
85    /// closure returns `Ok`; on `Err` it rolls back (SQLx rolls back an
86    /// uncommitted transaction) and returns the error.
87    ///
88    /// This preserves the raw SQLx escape hatch with explicit ownership and no
89    /// Arcature-added transaction semantics.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`DataError::Sqlx`] if `BEGIN`, the closure's query, or `COMMIT`
94    /// fails.
95    pub async fn sqlx<F, T>(db: &Db, f: F) -> Result<T, DataError>
96    where
97        F: for<'c> FnOnce(
98            &'c mut sqlx::Transaction<'_, sqlx::Postgres>,
99        ) -> std::pin::Pin<
100            Box<dyn Future<Output = Result<T, sqlx::Error>> + Send + 'c>,
101        >,
102        T: Send,
103    {
104        let mut txn = db.sqlx().begin().await.map_err(DataError::from)?;
105        let result = f(&mut txn).await;
106        match result {
107            Ok(value) => {
108                txn.commit().await.map_err(DataError::from)?;
109                Ok(value)
110            }
111            Err(error) => {
112                // On error, drop the transaction; SQLx rolls back an
113                // uncommitted transaction. Surface the original error.
114                drop(txn);
115                Err(DataError::from(error))
116            }
117        }
118    }
119}