Skip to main content

umbral_core/
db.rs

1//! Database pool registry and connection helpers.
2//!
3//! ## DbPool: the multi-backend seam
4//!
5//! [`DbPool`] is a small enum that wraps either a [`sqlx::SqlitePool`]
6//! or a [`sqlx::PgPool`]. It's the type [`connect`] returns and the
7//! type [`AppBuilder::database`](crate::app::AppBuilder::database)
8//! stores, so the framework remembers which backend each registered
9//! alias is connected to.
10//!
11//! ### Why an enum, not `sqlx::AnyPool`
12//!
13//! `sqlx::AnyPool` is the more "correct" abstraction at the type
14//! level: one pool type that dispatches to the right driver at
15//! runtime. But it has a real-world cost — sea-query-binder (the
16//! crate the QuerySet uses to bind parameters) doesn't have an
17//! `Any` backend; values must be bound through the per-driver
18//! query builder. Forcing every plugin and the queryset onto
19//! `AnyPool` therefore turns the simple multi-backend goal into a
20//! cascade through every binding site.
21//!
22//! The enum is the right shape. The migration engine and queryset
23//! dispatch on the variant through [`pool_dispatched`], so both
24//! backends work. Legacy SQLite-only call sites can still get a typed
25//! `SqlitePool` from [`pool`] / [`pool_for`] and use `sqlx::query(...)`
26//! against it unchanged (those panic on a Postgres pool, pointing the
27//! caller at the dispatch API).
28//!
29//! ### Postgres and the backend-dispatched accessors
30//!
31//! [`connect`] accepts both `sqlite://...` and `postgres://...`
32//! URLs and returns a [`DbPool`] of the matching variant. The
33//! detection mirrors [`crate::backend::detect`], so the boot path
34//! has one URL parser and they can't drift.
35//!
36//! Postgres is fully wired: the queryset and migration engine
37//! dispatch on the [`DbPool`] variant via [`pool_dispatched`] /
38//! [`pool_for_dispatched`]. The older [`pool`] / [`pool_for`]
39//! accessors still hand back a concrete `SqlitePool` and therefore
40//! panic on a Postgres pool with a message telling the caller to
41//! migrate to [`pool_dispatched`]. They remain only for legacy
42//! SQLite-only call sites that haven't moved to the dispatch API
43//! yet; new code should call [`pool_dispatched`] directly.
44
45use std::collections::HashMap;
46use std::pin::Pin;
47use std::sync::OnceLock;
48
49use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteSynchronous};
50use sqlx::{ConnectOptions, PgPool, SqlitePool};
51use std::str::FromStr;
52use std::time::Duration;
53
54pub mod route_context;
55pub mod router;
56
57pub use route_context::{RouteContext, TenantKey, current as route_context};
58pub use router::{Alias, DatabaseRouter, DefaultRouter, RouteOp, Schema, router};
59
60/// A pool of database connections, typed by backend.
61///
62/// Cloning is cheap — both variants wrap an `Arc`-backed inner
63/// pool, so a `clone()` just bumps the refcount.
64#[derive(Debug, Clone)]
65pub enum DbPool {
66    /// SQLite-backed connection pool. The default backend (SQLite for
67    /// tests / local dev, per the Postgres-first principle) and the one
68    /// the legacy concrete-`SqlitePool` accessors return directly.
69    Sqlite(SqlitePool),
70    /// Postgres-backed connection pool. Fully supported: the queryset
71    /// and migration engine dispatch on this variant through
72    /// [`pool_dispatched`]. Only the legacy concrete-`SqlitePool`
73    /// accessors ([`pool`] / [`pool_for`], via [`Self::sqlite_or_panic`])
74    /// reject it, with a message pointing at the dispatch API.
75    Postgres(PgPool),
76}
77
78impl DbPool {
79    /// Borrow the inner `SqlitePool`. Returns `None` for a Postgres
80    /// pool. Legacy SQLite-only callers that haven't migrated to the
81    /// dispatch API yet typically reach for [`Self::sqlite_or_panic`];
82    /// the returned-Option variant is for code that wants to
83    /// gracefully fall back.
84    pub fn as_sqlite(&self) -> Option<&SqlitePool> {
85        match self {
86            DbPool::Sqlite(p) => Some(p),
87            DbPool::Postgres(_) => None,
88        }
89    }
90
91    /// Borrow the inner `PgPool`. Returns `None` for a SQLite pool.
92    pub fn as_postgres(&self) -> Option<&PgPool> {
93        match self {
94            DbPool::Sqlite(_) => None,
95            DbPool::Postgres(p) => Some(p),
96        }
97    }
98
99    /// Borrow the inner `SqlitePool`, panicking on a Postgres variant.
100    /// Used by [`pool`] and [`pool_for`] so a legacy SQLite-only call
101    /// site doesn't quietly limp along when the operator connects to
102    /// Postgres. Postgres itself is fully supported — the fix is to
103    /// migrate the call site to [`pool_dispatched`], which dispatches
104    /// on the [`DbPool`] variant instead of assuming SQLite.
105    pub fn sqlite_or_panic(&self) -> &SqlitePool {
106        self.as_sqlite().expect(
107            "umbral: a Postgres pool is registered but this code path \
108             still reads a concrete SqlitePool. Migrate this call site to \
109             `umbral::db::pool_dispatched()` (or `pool_for_dispatched`) \
110             and dispatch on the DbPool variant — see the `DbPool` rustdoc.",
111        )
112    }
113
114    /// The string identifier of the underlying backend. Matches
115    /// [`crate::backend::DatabaseBackend::name`] for the active
116    /// pool variant.
117    pub fn backend_name(&self) -> &'static str {
118        match self {
119            DbPool::Sqlite(_) => "sqlite",
120            DbPool::Postgres(_) => "postgres",
121        }
122    }
123}
124
125impl From<SqlitePool> for DbPool {
126    fn from(pool: SqlitePool) -> Self {
127        DbPool::Sqlite(pool)
128    }
129}
130
131impl From<PgPool> for DbPool {
132    fn from(pool: PgPool) -> Self {
133        DbPool::Postgres(pool)
134    }
135}
136
137/// Holds all registered database pools, keyed by alias.
138/// The "default" pool is always present after `App::build()` succeeds.
139static POOLS: OnceLock<HashMap<String, DbPool>> = OnceLock::new();
140
141/// Runtime tenant-pool registry for **database-per-tenant** multitenancy:
142/// pools registered AFTER `App::build()`, as tenants are onboarded (e.g. by a
143/// `DatabaseRouter` that maps a request's tenant to its own database). The
144/// static `POOLS` map above is set once at build; this `RwLock`-backed map
145/// grows at runtime via [`register_tenant_pool`]. Stored pools are leaked to
146/// `&'static` on insert — a tenant pool lives for the whole process (you never
147/// drop one mid-serve), so [`pool_for_dispatched`] keeps its zero-cost
148/// `&'static DbPool` return: the `&'static` is copied out before the read guard
149/// drops, so no lock guard ever escapes.
150static DYNAMIC_POOLS: OnceLock<std::sync::RwLock<HashMap<String, &'static DbPool>>> =
151    OnceLock::new();
152
153/// Global default for whether ORM write terminals should wrap in a
154/// transaction. Set by `AppBuilder::atomic_transactions(...)`; read by
155/// every terminal that supports `.atomic()` / `.non_atomic()`. Unset
156/// (the default) means "no wrapping" — preserves existing behaviour for
157/// apps that don't opt in.
158static ATOMIC_DEFAULT: OnceLock<bool> = OnceLock::new();
159
160/// Publish the app-wide atomic-transactions default. Called by
161/// `AppBuilder::build()` exactly when the user set the flag via
162/// `atomic_transactions(...)`. Idempotent across re-init attempts —
163/// the first set wins, matching the rest of the OnceLock-backed
164/// ambient state.
165pub(crate) fn init_atomic_default(enabled: bool) {
166    let _ = ATOMIC_DEFAULT.set(enabled);
167}
168
169/// Read the app-wide atomic-transactions default. Returns `false` when
170/// the builder didn't call `atomic_transactions(...)` (or when the
171/// ambient state hasn't been published yet, as in unit tests that
172/// drive the ORM with `.on(&pool)` and never call `App::build()`).
173pub fn atomic_default() -> bool {
174    *ATOMIC_DEFAULT.get().unwrap_or(&false)
175}
176
177/// Initialize the pool registry. Called by `AppBuilder::build()` only.
178pub(crate) fn init(pools: HashMap<String, DbPool>) {
179    POOLS
180        .set(pools)
181        .expect("umbral::db::init called more than once");
182}
183
184/// Return the default connection pool, typed as a [`SqlitePool`].
185///
186/// Legacy SQLite-only accessor. The internal storage is a [`DbPool`];
187/// this unwraps to the `SqlitePool` variant or panics with a hint to
188/// migrate to [`pool_dispatched`] on a Postgres pool. New code should
189/// call [`pool_dispatched`] and dispatch on the variant.
190///
191/// # Panics
192///
193/// Panics if `App::build()` hasn't run or the registered default
194/// pool is Postgres.
195pub fn pool() -> SqlitePool {
196    pool_dispatched().sqlite_or_panic().clone()
197}
198
199/// Return the default connection pool as a typed [`DbPool`].
200///
201/// This is the backend-dispatched surface the migration engine and
202/// queryset use; it works on both SQLite and Postgres. Prefer it over
203/// the legacy [`pool`] accessor in new code.
204///
205/// # Panics
206///
207/// Panics if `App::build()` hasn't run.
208pub fn pool_dispatched() -> &'static DbPool {
209    POOLS
210        .get()
211        .expect("umbral: db pool not initialised — did you call App::build()?")
212        .get("default")
213        .expect("umbral: no default database registered")
214}
215
216/// Like [`pool_dispatched`] but returns `None` instead of panicking
217/// when no pool is registered yet (`App::build()` hasn't run, or this
218/// is a pure SQL-building call such as `QuerySet::to_sql` in a test with
219/// no app booted). Used by runtime advisory paths that must not crash a
220/// query-builder call — see the RIGHT-JOIN-on-old-SQLite warning.
221pub fn try_pool_dispatched() -> Option<&'static DbPool> {
222    POOLS.get().and_then(|pools| pools.get("default"))
223}
224
225/// Return a named connection pool, typed as a [`SqlitePool`].
226///
227/// # Panics
228///
229/// Panics if `App::build()` hasn't run, the alias isn't registered,
230/// or the registered pool is Postgres.
231pub fn pool_for(alias: &str) -> SqlitePool {
232    pool_for_dispatched(alias).sqlite_or_panic().clone()
233}
234
235/// Return a named connection pool as a typed [`DbPool`]. Phase 2
236/// surface; see [`pool_dispatched`].
237///
238/// Resolution order: the build-time `POOLS` map first, then the runtime
239/// [`register_tenant_pool`] registry (database-per-tenant). Panics only when
240/// the alias is in neither.
241pub fn pool_for_dispatched(alias: &str) -> &'static DbPool {
242    if let Some(p) = POOLS.get().and_then(|pools| pools.get(alias)) {
243        return p;
244    }
245    if let Some(p) = DYNAMIC_POOLS
246        .get()
247        .and_then(|reg| reg.read().ok().and_then(|m| m.get(alias).copied()))
248    {
249        return p;
250    }
251    if POOLS.get().is_none() {
252        panic!("umbral: db pool not initialised — did you call App::build()?");
253    }
254    panic!("umbral: no database registered under alias '{alias}'");
255}
256
257/// Register a database pool under `alias` at runtime — the database-per-tenant
258/// seam. Unlike the build-time `App::builder().database(alias, pool)` (which
259/// fills the static pool map), this may be called any time after `App::build()`
260/// as tenants are onboarded. First-write-wins: re-registering an existing alias
261/// is a no-op (a re-resolution of the same tenant won't churn its pool) and the
262/// surplus pool is dropped without leaking. The stored pool is leaked to
263/// `&'static` because tenant pools are process-lifetime.
264///
265/// A [`DatabaseRouter`](crate::db::router::DatabaseRouter) whose
266/// `db_for_read`/`db_for_write` returns `alias` for a tenant request then routes
267/// that tenant's queries to this pool.
268pub fn register_tenant_pool(alias: impl Into<String>, pool: DbPool) {
269    let alias = alias.into();
270    let mut guard = DYNAMIC_POOLS
271        .get_or_init(|| std::sync::RwLock::new(HashMap::new()))
272        .write()
273        .expect("umbral: dynamic pool registry poisoned");
274    if guard.contains_key(&alias) {
275        return; // first-write-wins; `pool` is dropped here, not leaked
276    }
277    let leaked: &'static DbPool = Box::leak(Box::new(pool));
278    guard.insert(alias, leaked);
279}
280
281/// True if `alias` resolves to a registered pool — build-time `POOLS` or the
282/// runtime tenant registry. A router can use this to fall back to the default
283/// pool for a tenant whose database hasn't been onboarded yet.
284pub fn pool_alias_registered(alias: &str) -> bool {
285    POOLS.get().is_some_and(|p| p.contains_key(alias))
286        || DYNAMIC_POOLS
287            .get()
288            .and_then(|reg| reg.read().ok().map(|m| m.contains_key(alias)))
289            .unwrap_or(false)
290}
291
292/// Ping the default database pool with a backend-appropriate liveness
293/// query (`SELECT 1`).
294///
295/// Resolves the ambient pool via [`pool_dispatched`] and dispatches:
296///
297/// - **SQLite** — `SELECT 1` via the sqlite driver.
298/// - **Postgres** — `SELECT 1` via the postgres driver.
299///
300/// Returns `Ok(())` when the pool is reachable. Returns
301/// `Err(sqlx::Error)` on any connection or query failure so callers
302/// can map it to a wire-friendly string without exposing the full sqlx
303/// error type.
304///
305/// # Panics
306///
307/// Panics if `App::build()` hasn't run (same contract as
308/// [`pool_dispatched`]).
309pub async fn ping() -> Result<(), sqlx::Error> {
310    match pool_dispatched() {
311        DbPool::Sqlite(p) => sqlx::query("SELECT 1").execute(p).await.map(|_| ()),
312        DbPool::Postgres(p) => sqlx::query("SELECT 1").execute(p).await.map(|_| ()),
313    }
314}
315
316/// features #73 — recompute a materialized view's stored rows.
317///
318/// A `#[umbral(materialized_view = "...")]` model serves rows that were computed
319/// once, at `CREATE MATERIALIZED VIEW` time. They do not update when the underlying
320/// tables change: that staleness IS the feature, and this is the call that ends it.
321///
322/// ```ignore
323/// umbral::db::refresh_view::<TeamStandings>().await?;
324/// ```
325///
326/// # Scheduling it
327///
328/// There is deliberately no `#[umbral(materialized_view = "...", refresh = "1h")]`.
329/// `umbral-core` cannot depend on `umbral-tasks` — that arrow points outward, and
330/// the whole crate graph exists to make that impossible. But you do not need it to:
331/// the scheduler is already a plugin, and this is a function, so
332///
333/// ```ignore
334/// #[task]
335/// async fn refresh_standings() -> Result<(), TaskError> {
336///     umbral::db::refresh_view::<TeamStandings>().await?;
337///     Ok(())
338/// }
339///
340/// TasksPlugin::new().periodic_task::<RefreshStandings>(Schedule::every_hours(1))
341/// ```
342///
343/// composes the two without either crate knowing the other exists. An attribute
344/// would have bought you nothing but a dependency edge pointing the wrong way.
345///
346/// # Errors
347///
348/// Returns an error on SQLite, which has no materialized views. In practice you
349/// cannot get here — the `model.materialized_view` system check fails the boot first
350/// — but a caller that reaches it should be told why rather than silently no-op.
351///
352/// # Panics
353///
354/// Panics if `App::build()` hasn't run (same contract as [`pool_dispatched`]).
355pub async fn refresh_view<M: crate::orm::Model>() -> Result<(), sqlx::Error> {
356    if !M::MATERIALIZED {
357        return Err(sqlx::Error::Protocol(format!(
358            "umbral::db::refresh_view::<{}>: only a `#[umbral(materialized_view = ...)]` \
359             model can be refreshed. A plain view recomputes on every read, so there is \
360             nothing to refresh; a table is not a view at all.",
361            M::NAME,
362        )));
363    }
364    match pool_dispatched() {
365        DbPool::Postgres(p) => sqlx::query(&format!("REFRESH MATERIALIZED VIEW \"{}\"", M::TABLE))
366            .execute(p)
367            .await
368            .map(|_| ()),
369        DbPool::Sqlite(_) => Err(sqlx::Error::Protocol(format!(
370            "umbral::db::refresh_view::<{}>: SQLite has no materialized views. The \
371             `model.materialized_view` system check should have failed this boot.",
372            M::NAME,
373        ))),
374    }
375}
376
377/// List every registered pool alias, sorted alphabetically.
378///
379/// Used by the migration engine to walk each DB in deterministic
380/// order so per-DB tracking tables get created and per-DB diffs run
381/// against the right model subset. The `"default"` alias is always
382/// present after `App::build()` succeeds and lands wherever
383/// alphabetical sort puts it (typically first).
384///
385/// # Panics
386///
387/// Panics if `App::build()` hasn't run.
388pub fn registered_aliases() -> Vec<String> {
389    let mut aliases: Vec<String> = POOLS
390        .get()
391        .expect("umbral: db pool not initialised — did you call App::build()?")
392        .keys()
393        .cloned()
394        .collect();
395    aliases.sort();
396    aliases
397}
398
399/// Open a new connection pool for the given database URL.
400///
401/// Dispatches on the URL scheme:
402///
403/// - `sqlite://...` or `sqlite::memory:` returns a
404///   [`DbPool::Sqlite`].
405/// - `postgres://...` / `postgresql://...` returns a
406///   [`DbPool::Postgres`].
407///
408/// Any other scheme surfaces as an `sqlx::Error::Configuration`.
409/// For callers that already have a typed pool, [`From`] impls on
410/// [`DbPool`] convert directly: `let dp: DbPool = sqlite_pool.into();`.
411pub async fn connect(url: &str) -> Result<DbPool, sqlx::Error> {
412    let scheme = url
413        .split("://")
414        .next()
415        .and_then(|s| s.split(':').next())
416        .unwrap_or(url);
417    match scheme {
418        "sqlite" => Ok(DbPool::Sqlite(connect_sqlite(url).await?)),
419        "postgres" | "postgresql" => Ok(DbPool::Postgres(connect_postgres(url).await?)),
420        other => Err(sqlx::Error::Configuration(
421            format!(
422                "umbral::db::connect: unsupported URL scheme `{other}://`. \
423                 Phase 1 supports `sqlite://` and `postgres://`."
424            )
425            .into(),
426        )),
427    }
428}
429
430/// Open a pool LAZILY from a URL — synchronous, connects on first use (audit_2
431/// H17). This is what `App::build()` uses to open the pools declared in
432/// `settings.databases`, which it can't do with the async [`connect`] because
433/// `build()` is a sync fn. Same backend dispatch and pool config as [`connect`].
434pub fn connect_lazy(url: &str) -> Result<DbPool, sqlx::Error> {
435    let scheme = url
436        .split("://")
437        .next()
438        .and_then(|s| s.split(':').next())
439        .unwrap_or(url);
440    match scheme {
441        "sqlite" => Ok(DbPool::Sqlite(connect_sqlite_lazy(url)?)),
442        "postgres" | "postgresql" => Ok(DbPool::Postgres(connect_postgres_lazy(url)?)),
443        other => Err(sqlx::Error::Configuration(
444            format!("umbral::db::connect_lazy: unsupported URL scheme `{other}://`.").into(),
445        )),
446    }
447}
448
449/// The effective pool configuration, resolved from [`crate::settings`]
450/// when installed and falling back to the documented production defaults
451/// otherwise (a pool can be opened before settings are installed). Shared
452/// by [`connect_postgres`] and [`connect_sqlite`] so both backends honour
453/// the same `UMBRAL_DB_*` knobs (gaps2 #91).
454struct PoolConfig {
455    max_connections: u32,
456    min_connections: u32,
457    acquire_timeout_secs: u64,
458    idle_timeout_secs: Option<u64>,
459    max_lifetime_secs: Option<u64>,
460    test_before_acquire: bool,
461}
462
463impl PoolConfig {
464    fn from_settings(s: &crate::settings::Settings) -> Self {
465        PoolConfig {
466            max_connections: s.db_max_connections,
467            min_connections: s.db_min_connections,
468            acquire_timeout_secs: s.db_acquire_timeout_secs,
469            idle_timeout_secs: s.db_idle_timeout_secs,
470            max_lifetime_secs: s.db_max_lifetime_secs,
471            test_before_acquire: s.db_test_before_acquire,
472        }
473    }
474
475    fn resolve() -> Self {
476        // Prefer the ambient settings once published by `App::build()`.
477        if let Some(s) = crate::settings::get_opt() {
478            return PoolConfig::from_settings(s);
479        }
480        // audit_2 H16: the default pool is opened via `db::connect()` BEFORE
481        // `App::build()` in every documented boot path, so at this point the
482        // ambient settings aren't published yet. Re-read the `UMBRAL_DB_*` knobs
483        // straight from the environment (same figment parse `build()` uses) so
484        // an operator's `UMBRAL_DB_MAX_CONNECTIONS=100` isn't silently discarded
485        // for the pool that serves ALL traffic. Only if the env can't be parsed
486        // do we fall back to the hardcoded production defaults.
487        match crate::settings::Settings::from_env() {
488            Ok(s) => PoolConfig::from_settings(&s),
489            // Defaults mirror the `default_db_*` fns in `settings`.
490            Err(_) => PoolConfig {
491                max_connections: 10,
492                min_connections: 0,
493                acquire_timeout_secs: 30,
494                idle_timeout_secs: Some(600),
495                max_lifetime_secs: Some(1800),
496                test_before_acquire: true,
497            },
498        }
499    }
500
501    /// Emit one operator-facing line describing the pool that's about to
502    /// be built, so the effective config is visible in the boot log.
503    fn log(&self, backend: &str) {
504        tracing::info!(
505            backend,
506            max_connections = self.max_connections.max(1),
507            min_connections = self.min_connections,
508            acquire_timeout_secs = self.acquire_timeout_secs,
509            idle_timeout_secs = ?self.idle_timeout_secs,
510            max_lifetime_secs = ?self.max_lifetime_secs,
511            test_before_acquire = self.test_before_acquire,
512            "umbral: opening database pool"
513        );
514    }
515}
516
517/// Open a Postgres pool from a URL with umbral's pool configuration.
518///
519/// Set true the first time any request populates `RouteContext` session vars,
520/// so the Postgres `before_acquire` hook only pays the reset+set round-trips
521/// for apps that actually use them (RLS / per-connection GUCs). Apps that never
522/// set a session var never flip it → zero added overhead. audit_2 C2/R2.
523static SESSION_VARS_IN_USE: std::sync::atomic::AtomicBool =
524    std::sync::atomic::AtomicBool::new(false);
525
526/// PERF-5 / gaps2 #91: bare `PgPool::connect` uses sqlx's defaults with
527/// **no acquire timeout**, so a saturated pool blocks request tasks
528/// forever. We always apply the full set of pool knobs — `max_connections`,
529/// `min_connections`, a bounded `acquire_timeout` (fail fast),
530/// `idle_timeout`, `max_lifetime`, and `test_before_acquire` — read from
531/// [`crate::settings`] when available (falling back to the documented
532/// production defaults if the pool is opened before settings are
533/// installed). `idle_timeout`/`max_lifetime` are only applied when `Some`;
534/// a `None` (env `0`/empty) leaves that recycling disabled.
535pub async fn connect_postgres(url: &str) -> Result<PgPool, sqlx::Error> {
536    pg_pool_options().connect(url).await
537}
538
539/// Open a Postgres pool LAZILY (audit_2 H17): the pool object is created
540/// synchronously and connects on first use. This is what lets `App::build()`
541/// (a sync fn) open the pools declared in `settings.databases` without an async
542/// context. Same [`PoolConfig`] knobs and the same RLS `before_acquire` GUC
543/// hook as the eager [`connect_postgres`].
544pub fn connect_postgres_lazy(url: &str) -> Result<PgPool, sqlx::Error> {
545    pg_pool_options().connect_lazy(url)
546}
547
548/// Shared `PgPoolOptions` builder for the eager + lazy connect paths, so the
549/// pool knobs and the RLS session-var hook never drift between them.
550fn pg_pool_options() -> sqlx::postgres::PgPoolOptions {
551    use std::time::Duration;
552    let cfg = PoolConfig::resolve();
553    cfg.log("postgres");
554
555    let mut opts = sqlx::postgres::PgPoolOptions::new()
556        .max_connections(cfg.max_connections.max(1))
557        .min_connections(cfg.min_connections)
558        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
559        .test_before_acquire(cfg.test_before_acquire)
560        // audit_2 C2/R2 — apply the request's RouteContext session variables
561        // (GUCs) to the connection it's about to use, so RLS policies that read
562        // `current_setting('app.user_id')` see the right value. before_acquire
563        // runs inside the acquiring request's task, so the task-local
564        // RouteContext is visible. We RESET ALL first to clear any GUC a PRIOR
565        // request left on this pooled connection (the leak the audit calls out),
566        // then set the current request's. A process-wide flag keeps apps that
567        // never use session vars paying zero extra round-trips.
568        .before_acquire(|conn, _meta| {
569            Box::pin(async move {
570                let ctx = route_context::current();
571                let vars = ctx.session_vars();
572                if !vars.is_empty() {
573                    SESSION_VARS_IN_USE.store(true, std::sync::atomic::Ordering::Relaxed);
574                }
575                if SESSION_VARS_IN_USE.load(std::sync::atomic::Ordering::Relaxed) {
576                    sqlx::query("RESET ALL").execute(&mut *conn).await?;
577                    for (name, value) in vars {
578                        sqlx::query("SELECT set_config($1, $2, false)")
579                            .bind(name)
580                            .bind(value)
581                            .execute(&mut *conn)
582                            .await?;
583                    }
584                }
585                Ok(true)
586            })
587        });
588    if let Some(secs) = cfg.idle_timeout_secs {
589        opts = opts.idle_timeout(Duration::from_secs(secs));
590    }
591    if let Some(secs) = cfg.max_lifetime_secs {
592        opts = opts.max_lifetime(Duration::from_secs(secs));
593    }
594    opts
595}
596
597/// Open a SQLite-backed pool from a URL.
598///
599/// Applies the standard production PRAGMAs to every connection in the
600/// pool: WAL journal, NORMAL synchronous, a 5-second busy-timeout, and
601/// foreign-key enforcement on. Without these, a fresh `SqlitePool` ends
602/// up in `journal_mode = DELETE` + `synchronous = FULL` — the safe
603/// SQLite defaults that cost ~1-4 seconds per concurrent INSERT once
604/// any other connection touches the file (the rollback-journal lock
605/// serialises writers).
606///
607/// | PRAGMA | Value | Why |
608/// |---|---|---|
609/// | `journal_mode` | `WAL` | Readers don't block writers; a single writer at a time but no full-file lock. Order-of-magnitude faster for any concurrent workload — typically the session/auth/audit tables fanning out. |
610/// | `synchronous` | `NORMAL` | Skips the per-commit fsync of the rollback journal; safe with WAL since the WAL log is fsynced on checkpoint. The official SQLite docs call this the right pairing with WAL for "most applications". |
611/// | `busy_timeout` | `5000ms` | Wait up to 5 s for a contended writer to release the lock before raising `SQLITE_BUSY`. Without this, two concurrent writers immediately race to error. |
612/// | `foreign_keys` | `ON` | sqlite turns FK enforcement off by default. The ORM emits `REFERENCES` clauses assuming they're respected — turning it on per connection makes the FK contract real. |
613///
614/// **In-memory URLs are backed by a process-unique temp file.** A bare
615/// `sqlite::memory:` gives every connection in the pool its OWN private,
616/// empty database, so a table created on one connection is invisible to a
617/// query that lands on another — and a shared in-memory database doesn't
618/// survive the connection (or the tokio runtime) that created it being
619/// dropped. Both surface as a flaky "no such table" whenever a pool is
620/// reused across queries or test cases. Routing in-memory URLs through a
621/// small temp file (which every connection sees and which persists for the
622/// process) sidesteps both — the same approach `umbral-testing::TempPool`
623/// already documents. File-backed (`sqlite://app.db`) and Postgres URLs are
624/// untouched.
625pub async fn connect_sqlite(url: &str) -> Result<SqlitePool, sqlx::Error> {
626    let (pool_opts, opts) = sqlite_options(url)?;
627    pool_opts.connect_with(opts).await
628}
629
630/// Open a SQLite pool LAZILY (audit_2 H17): the pool is created synchronously
631/// and connects on first use, so `App::build()` can open `settings.databases`
632/// entries without an async context. Same PRAGMAs and [`PoolConfig`] knobs as
633/// the eager [`connect_sqlite`].
634pub fn connect_sqlite_lazy(url: &str) -> Result<SqlitePool, sqlx::Error> {
635    let (pool_opts, opts) = sqlite_options(url)?;
636    Ok(pool_opts.connect_lazy_with(opts))
637}
638
639/// Shared SQLite options builder (pool knobs + connection PRAGMAs + in-memory
640/// temp-file handling) for the eager + lazy connect paths, so they never drift.
641fn sqlite_options(url: &str) -> Result<(SqlitePoolOptions, SqliteConnectOptions), sqlx::Error> {
642    use std::sync::atomic::{AtomicU64, Ordering};
643    static MEM_SEQ: AtomicU64 = AtomicU64::new(0);
644
645    let lower = url.to_ascii_lowercase();
646    let in_memory = lower.contains(":memory:") || lower.contains("mode=memory");
647
648    let opts = if in_memory {
649        let n = MEM_SEQ.fetch_add(1, Ordering::Relaxed);
650        let path =
651            std::env::temp_dir().join(format!("umbral_mem_{}_{n}.sqlite", std::process::id()));
652        // Best-effort: remove a stale file from a previous run with this
653        // exact (pid, seq) — pids recycle. WAL/SHM siblings are recreated.
654        let _ = std::fs::remove_file(&path);
655        SqliteConnectOptions::new()
656            .filename(&path)
657            .create_if_missing(true)
658    } else {
659        SqliteConnectOptions::from_str(url)?
660    };
661    let opts = opts
662        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
663        .synchronous(SqliteSynchronous::Normal)
664        .busy_timeout(Duration::from_secs(5))
665        .foreign_keys(true)
666        // Disable per-statement logging — sqlx's default INFO-level
667        // logger reads every statement before execution, which adds a
668        // measurable per-query overhead under load. The `slow statement`
669        // WARN at the 1-second threshold stays on, since it goes via a
670        // separate log target.
671        .log_statements(tracing::log::LevelFilter::Off);
672
673    // gaps2 #91: apply the same settings-driven pool knobs as Postgres so
674    // a single `UMBRAL_DB_*` configuration governs every backend. SQLite is
675    // effectively single-writer (WAL serialises writers behind one lock),
676    // so a large `max_connections` mainly buys concurrent *readers*; the
677    // knob is still honoured rather than hardcoding a divergent SQLite path.
678    let cfg = PoolConfig::resolve();
679    cfg.log("sqlite");
680    let mut pool_opts = SqlitePoolOptions::new()
681        .max_connections(cfg.max_connections.max(1))
682        .min_connections(cfg.min_connections)
683        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
684        .test_before_acquire(cfg.test_before_acquire);
685    if let Some(secs) = cfg.idle_timeout_secs {
686        pool_opts = pool_opts.idle_timeout(Duration::from_secs(secs));
687    }
688    if let Some(secs) = cfg.max_lifetime_secs {
689        pool_opts = pool_opts.max_lifetime(Duration::from_secs(secs));
690    }
691    Ok((pool_opts, opts))
692}
693
694/// Gracefully close the ambient default database pool (gaps2 #91).
695///
696/// Call this once during shutdown — after the HTTP server has stopped
697/// accepting connections — to let sqlx flush in-flight work and close
698/// every pooled connection cleanly rather than having them dropped
699/// abruptly when the process exits. For SQLite this also lets WAL
700/// checkpoint; for Postgres it sends a clean `Terminate` so the server
701/// doesn't log the connections as unexpectedly lost.
702///
703/// Closing is terminal: the ambient [`OnceLock`] is left in place (it
704/// can't be unset), so the pool object remains registered but is closed.
705/// Acquiring from a closed pool errors, which is the intended post-
706/// shutdown behaviour. A no-op if no pool was ever registered.
707///
708/// ```rust,ignore
709/// // in your shutdown handler, after the server stops:
710/// umbral::db::close().await;
711/// ```
712pub async fn close() {
713    if let Some(pools) = POOLS.get() {
714        for db in pools.values() {
715            match db {
716                DbPool::Sqlite(p) => p.close().await,
717                DbPool::Postgres(p) => p.close().await,
718            }
719        }
720    }
721}
722
723// =============================================================================
724// Transaction support
725// =============================================================================
726
727/// An active database transaction, typed by backend.
728///
729/// `Transaction` wraps either a `sqlx::Transaction<'static, sqlx::Sqlite>` or
730/// a `sqlx::Transaction<'static, sqlx::Postgres>` and provides the executor
731/// surface needed by the ORM's query terminals.
732///
733/// ## How to obtain one
734///
735/// The typical path is through the top-level closure helpers:
736///
737/// ```rust,ignore
738/// use umbral::db::transaction;
739///
740/// let order = transaction(|tx| async move {
741///     let o = Order::objects().on_tx(tx).create(new_order).await?;
742///     Inventory::objects().on_tx(tx).filter(...).update_values(...).await?;
743///     Ok::<_, MyError>(o)
744/// }).await?;
745/// ```
746///
747/// For manual control (committing or rolling back yourself) call
748/// [`begin`] / [`begin_sqlite`] / [`begin_pg`] directly.
749///
750/// ## Executor contract
751///
752/// The `as_sqlite_mut` / `as_pg_mut` accessors return a mutable reference to
753/// the underlying sqlx transaction so ORM internals can call
754/// `sqlx::query(...).execute(&mut *inner)`. Both the `QuerySet::on_tx` and
755/// `Manager::create_in_tx` methods receive `&mut Transaction` and dispatch
756/// through these accessors.
757pub struct Transaction {
758    inner: TransactionInner,
759}
760
761enum TransactionInner {
762    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
763    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
764}
765
766impl Transaction {
767    /// Return a mutable reference to the inner SQLite transaction, or `None`
768    /// when this is a Postgres transaction.
769    pub fn as_sqlite_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Sqlite>> {
770        match &mut self.inner {
771            TransactionInner::Sqlite(tx) => Some(tx),
772            TransactionInner::Postgres(_) => None,
773        }
774    }
775
776    /// Return a mutable reference to the inner Postgres transaction, or `None`
777    /// when this is a SQLite transaction.
778    pub fn as_pg_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Postgres>> {
779        match &mut self.inner {
780            TransactionInner::Sqlite(_) => None,
781            TransactionInner::Postgres(tx) => Some(tx),
782        }
783    }
784
785    /// The backend name — `"sqlite"` or `"postgres"`. Mirrors
786    /// [`DbPool::backend_name`] so shared dispatch helpers can use the same
787    /// match arm.
788    pub fn backend_name(&self) -> &'static str {
789        match &self.inner {
790            TransactionInner::Sqlite(_) => "sqlite",
791            TransactionInner::Postgres(_) => "postgres",
792        }
793    }
794
795    /// Commit the transaction explicitly.
796    ///
797    /// The closure-based helpers ([`transaction`] / [`transaction_sqlite`] /
798    /// [`transaction_pg`]) call this automatically on `Ok`. Use this only
799    /// when you obtained the transaction via [`begin`] / [`begin_sqlite`] /
800    /// [`begin_pg`] and are driving the lifecycle yourself.
801    pub async fn commit(self) -> Result<(), sqlx::Error> {
802        match self.inner {
803            TransactionInner::Sqlite(tx) => tx.commit().await,
804            TransactionInner::Postgres(tx) => tx.commit().await,
805        }
806    }
807
808    /// Roll back the transaction explicitly.
809    ///
810    /// The closure-based helpers call this automatically on `Err`. Use this
811    /// only in the manual-control pattern.
812    pub async fn rollback(self) -> Result<(), sqlx::Error> {
813        match self.inner {
814            TransactionInner::Sqlite(tx) => tx.rollback().await,
815            TransactionInner::Postgres(tx) => tx.rollback().await,
816        }
817    }
818}
819
820/// Begin a transaction against the ambient pool.
821///
822/// The `Transaction` is dropped-and-rolled-back if neither `commit` nor
823/// `rollback` is called before it goes out of scope (sqlx's drop impl).
824/// Most callers use the higher-level [`transaction`] / [`transaction_sqlite`]
825/// / [`transaction_pg`] closures instead.
826///
827/// # Panics
828///
829/// Panics if `App::build()` hasn't run.
830pub async fn begin() -> Result<Transaction, sqlx::Error> {
831    match pool_dispatched() {
832        DbPool::Sqlite(pool) => {
833            // `BEGIN IMMEDIATE`: acquire the write lock at BEGIN so a contending
834            // writer WAITS (busy_timeout) instead of hitting the deferred-upgrade
835            // SQLITE_BUSY (SQLite skips the busy handler for a read→write upgrade
836            // to avoid deadlock). Postgres keeps the default (deferred) begin.
837            let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
838            Ok(Transaction {
839                inner: TransactionInner::Sqlite(tx),
840            })
841        }
842        DbPool::Postgres(pool) => {
843            let tx = pool.begin().await?;
844            Ok(Transaction {
845                inner: TransactionInner::Postgres(tx),
846            })
847        }
848    }
849}
850
851/// Begin a transaction against the pool registered under `alias` (audit_2
852/// core-app-config #5).
853///
854/// [`begin`] / [`transaction`] always target the `"default"` pool — they
855/// consult neither the [`DatabaseRouter`] nor per-model aliases nor the tenant
856/// route context. In a multi-DB or DB-per-tenant app, a model routed to a
857/// replica/tenant alias run inside a plain `transaction()` would execute its
858/// SQL on the DEFAULT database — a silent wrong-database write. Use this to
859/// pin the transaction to the intended pool; `Model::objects().on_tx(&mut tx)`
860/// then runs every statement on `alias`'s pool regardless of the model's own
861/// routing.
862///
863/// # Panics
864///
865/// Panics if `App::build()` hasn't run, or if no pool is registered under
866/// `alias` (same contract as [`pool_for_dispatched`]).
867pub async fn begin_for(alias: &str) -> Result<Transaction, sqlx::Error> {
868    match pool_for_dispatched(alias) {
869        DbPool::Sqlite(pool) => Ok(Transaction {
870            // BEGIN IMMEDIATE for SQLite — see `begin()`.
871            inner: TransactionInner::Sqlite(pool.begin_with("BEGIN IMMEDIATE").await?),
872        }),
873        DbPool::Postgres(pool) => Ok(Transaction {
874            inner: TransactionInner::Postgres(pool.begin().await?),
875        }),
876    }
877}
878
879/// Begin a transaction against an explicit SQLite pool.
880pub async fn begin_sqlite(pool: &sqlx::SqlitePool) -> Result<Transaction, sqlx::Error> {
881    // BEGIN IMMEDIATE for SQLite — see `begin()`.
882    let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
883    Ok(Transaction {
884        inner: TransactionInner::Sqlite(tx),
885    })
886}
887
888/// Begin a transaction against an explicit Postgres pool.
889pub async fn begin_pg(pool: &sqlx::PgPool) -> Result<Transaction, sqlx::Error> {
890    let tx = pool.begin().await?;
891    Ok(Transaction {
892        inner: TransactionInner::Postgres(tx),
893    })
894}
895
896/// Pinned, boxed `Future` with a lifetime parameter.
897///
898/// This is the required shape for the closure argument to
899/// [`transaction`] / [`transaction_sqlite`] / [`transaction_pg`].
900/// The lifetime `'a` ties the future to the `&'a mut Transaction`
901/// reference so the borrow checker can verify that the transaction
902/// outlives the async work being done inside it.
903///
904/// Call sites construct this by calling `.boxed()` or wrapping the
905/// `async move` block:
906///
907/// ```rust,ignore
908/// use futures::FutureExt;
909/// use umbral::db::{transaction, TxFuture};
910///
911/// transaction(|tx| {
912///     Box::pin(async move {
913///         Post::objects().on_tx(tx).create(new_post).await?;
914///         Ok::<_, MyError>(())
915///     })
916/// }).await?;
917/// ```
918///
919/// The `async move { ... }` block captures the `&mut Transaction` by
920/// move and the `Box::pin(...)` wrapper satisfies the HRTB bound.
921pub type TxFuture<'a, T, E> = Pin<Box<dyn std::future::Future<Output = Result<T, E>> + Send + 'a>>;
922
923/// Run an async closure inside a database transaction against the ambient pool.
924///
925/// The closure receives `&mut Transaction`. On `Ok` the transaction is
926/// committed; on `Err` it is rolled back. Returns the closure's `Ok` value
927/// on success.
928///
929/// The closure must return a `TxFuture` (a `Pin<Box<dyn Future>>`).
930/// Use `Box::pin(async move { ... })`:
931///
932/// ```rust,ignore
933/// use umbral::db::transaction;
934///
935/// let order = transaction(|tx| Box::pin(async move {
936///     let o = Order::objects().on_tx(tx).create(new_order).await?;
937///     Inventory::objects()
938///         .on_tx(tx)
939///         .filter(inv::PRODUCT_ID.eq(sku))
940///         .update_values(delta)
941///         .await?;
942///     Ok::<_, MyError>(o)
943/// })).await?;
944/// ```
945///
946/// # Panics
947///
948/// Panics if `App::build()` hasn't run.
949pub async fn transaction<F, T, E>(f: F) -> Result<T, E>
950where
951    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
952    E: From<sqlx::Error>,
953{
954    let mut tx = begin().await.map_err(E::from)?;
955    match f(&mut tx).await {
956        Ok(val) => {
957            tx.commit().await.map_err(E::from)?;
958            Ok(val)
959        }
960        Err(e) => {
961            // Best-effort rollback — if it fails we surface the original error.
962            let _ = tx.rollback().await;
963            Err(e)
964        }
965    }
966}
967
968/// Run an async closure inside a transaction against the pool registered under
969/// `alias` (audit_2 core-app-config #5) — the alias-aware sibling of
970/// [`transaction`]. Use this for a multi-DB / DB-per-tenant app so the
971/// transaction (and every `on_tx` statement inside it) runs on the RIGHT
972/// database instead of silently on `"default"`. See [`begin_for`] for the
973/// routing rationale and panics.
974///
975/// ```rust,ignore
976/// use umbral::db::transaction_on;
977///
978/// transaction_on("replica_writes", |tx| Box::pin(async move {
979///     Ledger::objects().on_tx(tx).create(entry).await?;
980///     Ok::<_, MyError>(())
981/// })).await?;
982/// ```
983pub async fn transaction_on<F, T, E>(alias: &str, f: F) -> Result<T, E>
984where
985    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
986    E: From<sqlx::Error>,
987{
988    let mut tx = begin_for(alias).await.map_err(E::from)?;
989    match f(&mut tx).await {
990        Ok(val) => {
991            tx.commit().await.map_err(E::from)?;
992            Ok(val)
993        }
994        Err(e) => {
995            // Best-effort rollback — if it fails we surface the original error.
996            let _ = tx.rollback().await;
997            Err(e)
998        }
999    }
1000}
1001
1002/// Run an async closure inside a SQLite transaction against an explicit pool.
1003///
1004/// The SQLite-specific variant of [`transaction`] for callers that want to
1005/// pin to SQLite regardless of what the ambient pool is, or that are running
1006/// outside of `App::build()` (e.g. tests).
1007///
1008/// See [`transaction`] for the closure shape.
1009pub async fn transaction_sqlite<F, T, E>(pool: &sqlx::SqlitePool, f: F) -> Result<T, E>
1010where
1011    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1012    E: From<sqlx::Error>,
1013{
1014    let mut tx = begin_sqlite(pool).await.map_err(E::from)?;
1015    match f(&mut tx).await {
1016        Ok(val) => {
1017            tx.commit().await.map_err(E::from)?;
1018            Ok(val)
1019        }
1020        Err(e) => {
1021            let _ = tx.rollback().await;
1022            Err(e)
1023        }
1024    }
1025}
1026
1027/// Run an async closure inside a Postgres transaction against an explicit pool.
1028///
1029/// The Postgres-specific variant of [`transaction`] for callers that want to
1030/// pin to Postgres or run outside `App::build()`.
1031///
1032/// See [`transaction`] for the closure shape.
1033pub async fn transaction_pg<F, T, E>(pool: &sqlx::PgPool, f: F) -> Result<T, E>
1034where
1035    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1036    E: From<sqlx::Error>,
1037{
1038    let mut tx = begin_pg(pool).await.map_err(E::from)?;
1039    match f(&mut tx).await {
1040        Ok(val) => {
1041            tx.commit().await.map_err(E::from)?;
1042            Ok(val)
1043        }
1044        Err(e) => {
1045            let _ = tx.rollback().await;
1046            Err(e)
1047        }
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054
1055    // `pool` and `pool_for` read the process-wide `POOLS` `OnceLock`, which
1056    // can only be set once per process. Under cargo test's parallel runner
1057    // that makes them unreliable to cover directly without `serial_test` or
1058    // a refactor, so they're intentionally out of scope here. Same reason
1059    // the "pool() panics before init" path isn't exercised: another test in
1060    // the same process may have already populated the lock.
1061    //
1062    // Mirrors the settings module's stance on its own `init`/`get` pair.
1063
1064    /// `connect` hands back a SQLite pool wrapped in `DbPool::Sqlite` we
1065    /// can actually run queries through.
1066    #[tokio::test]
1067    async fn connect_returns_a_working_pool_against_in_memory_sqlite() {
1068        let pool = connect("sqlite::memory:")
1069            .await
1070            .expect("in-memory sqlite should always connect");
1071
1072        let sqlite = pool.as_sqlite().expect("should be Sqlite variant");
1073        let (one,): (i64,) = sqlx::query_as("SELECT 1")
1074            .fetch_one(sqlite)
1075            .await
1076            .expect("SELECT 1 should succeed on a fresh pool");
1077
1078        assert_eq!(one, 1);
1079    }
1080
1081    /// A URL sqlx can't parse surfaces as a plain `sqlx::Error`. We don't
1082    /// pin the variant — the family is the contract.
1083    #[tokio::test]
1084    async fn connect_errors_on_malformed_url() {
1085        let result = connect("not-a-real-url").await;
1086        assert!(
1087            result.is_err(),
1088            "expected sqlx to reject a malformed url, got Ok"
1089        );
1090    }
1091
1092    /// MySQL and similar schemes that umbral hasn't shipped yet
1093    /// surface as a clear configuration error rather than a
1094    /// driver-internal one.
1095    #[tokio::test]
1096    async fn connect_rejects_unsupported_scheme() {
1097        let result = connect("mysql://user:pass@host/db").await;
1098        match result {
1099            Err(sqlx::Error::Configuration(msg)) => {
1100                assert!(msg.to_string().contains("mysql"));
1101            }
1102            other => panic!("expected Configuration error, got {other:?}"),
1103        }
1104    }
1105
1106    /// `From<SqlitePool>` and the variant accessors round-trip.
1107    #[tokio::test]
1108    async fn sqlite_pool_round_trips_through_dbpool() {
1109        let sp = SqlitePool::connect("sqlite::memory:").await.unwrap();
1110        let dp: DbPool = sp.clone().into();
1111        assert_eq!(dp.backend_name(), "sqlite");
1112        assert!(dp.as_sqlite().is_some());
1113        assert!(dp.as_postgres().is_none());
1114    }
1115}