arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! Typed transactions over the SeaORM and SQLx paths, with explicit ownership
//! and no hidden nesting/savepoint magic (PROGRAM.md AP2.1-6).
//!
//! [`Transaction::orm`] delegates to SeaORM's `TransactionTrait::transaction`
//! over the explicit `&Db`. [`Transaction::sqlx`] begins a raw SQLx transaction
//! over `db.sqlx()`. Arcature adds **no** additional savepoint, auto-nesting, or
//! silent rollback semantics beyond what the underlying library does:
//!
//! - SeaORM's `transaction` runs the closure against a transactional connection
//!   and **rolls back when the closure returns `Err`** — that is SeaORM's
//!   documented behavior, not Arcature magic.
//! - SQLx rolls back a `Transaction` that is dropped without `commit`; this
//!   closure wrapper commits on `Ok` and returns the error (leaving rollback to
//!   SQLx) on `Err`.
//!
//! Both take the `&Db` by reference. There is no thread-local, task-local, or
//! request-global transaction context (AGENTS.md §20).

use std::future::Future;

use arcature_db::Db;
use arcature_db::sea_orm;
use arcature_db::sea_orm::TransactionTrait;
use arcature_db::sqlx;

use crate::error::DataError;

/// Typed transactions over the explicit `&Db` — SeaORM and SQLx paths.
///
/// This is a namespace for the two transaction entry points; it holds no state.
/// Construct nothing — call [`Transaction::orm`] or [`Transaction::sqlx`] with
/// an explicit `&Db`.
pub struct Transaction;

impl Transaction {
    /// Run a closure inside a SeaORM transaction over `db.orm()`.
    ///
    /// The closure receives a `&sea_orm::DatabaseTransaction` to pass to
    /// SeaORM operations (e.g. `active.insert(txn)`). SeaORM commits when the
    /// closure returns `Ok` and rolls back when it returns `Err` — that is
    /// SeaORM's transaction semantics, surfaced without additional magic.
    ///
    /// ```ignore
    /// use arcature_data::Transaction;
    /// # async fn run(db: &arcature_db::Db) -> Result<(), arcature_data::DataError> {
    /// Transaction::orm(db, |txn| Box::pin(async move {
    ///     // SeaORM operations against `txn`…
    ///     Ok::<_, arcature_db::sea_orm::DbErr>(())
    /// }))
    /// .await?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`DataError::Database`] if the transaction fails (the closure
    /// returned `Err`, or the commit/rollback itself errored).
    pub async fn orm<F, T>(db: &Db, f: F) -> Result<T, DataError>
    where
        F: for<'c> FnOnce(
                &'c sea_orm::DatabaseTransaction,
            ) -> std::pin::Pin<
                Box<dyn Future<Output = Result<T, sea_orm::DbErr>> + Send + 'c>,
            > + Send,
        T: Send,
    {
        let result = db.orm().transaction(f).await;
        result.map_err(|error| {
            // Both variants of `TransactionError<DbErr>` carry a `DbErr`:
            // `Connection(DbErr)` (BEGIN/COMMIT/ROLLBACK failure) and
            // `Transaction(DbErr)` (the closure returned `Err`).
            let db_err = match error {
                sea_orm::TransactionError::Connection(err) => err,
                sea_orm::TransactionError::Transaction(err) => err,
            };
            DataError::from(db_err)
        })
    }

    /// Run a closure inside a raw SQLx transaction over `db.sqlx()`.
    ///
    /// The closure receives a `&mut sqlx::Transaction` for raw SQLx operations
    /// (`sqlx::query!(...).execute(&mut **txn)`). The wrapper commits when the
    /// closure returns `Ok`; on `Err` it rolls back (SQLx rolls back an
    /// uncommitted transaction) and returns the error.
    ///
    /// This preserves the raw SQLx escape hatch with explicit ownership and no
    /// Arcature-added transaction semantics.
    ///
    /// # Errors
    ///
    /// Returns [`DataError::Sqlx`] if `BEGIN`, the closure's query, or `COMMIT`
    /// fails.
    pub async fn sqlx<F, T>(db: &Db, f: F) -> Result<T, DataError>
    where
        F: for<'c> FnOnce(
            &'c mut sqlx::Transaction<'_, sqlx::Postgres>,
        ) -> std::pin::Pin<
            Box<dyn Future<Output = Result<T, sqlx::Error>> + Send + 'c>,
        >,
        T: Send,
    {
        let mut txn = db.sqlx().begin().await.map_err(DataError::from)?;
        let result = f(&mut txn).await;
        match result {
            Ok(value) => {
                txn.commit().await.map_err(DataError::from)?;
                Ok(value)
            }
            Err(error) => {
                // On error, drop the transaction; SQLx rolls back an
                // uncommitted transaction. Surface the original error.
                drop(txn);
                Err(DataError::from(error))
            }
        }
    }
}