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}