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/// List every registered pool alias, sorted alphabetically.
317///
318/// Used by the migration engine to walk each DB in deterministic
319/// order so per-DB tracking tables get created and per-DB diffs run
320/// against the right model subset. The `"default"` alias is always
321/// present after `App::build()` succeeds and lands wherever
322/// alphabetical sort puts it (typically first).
323///
324/// # Panics
325///
326/// Panics if `App::build()` hasn't run.
327pub fn registered_aliases() -> Vec<String> {
328    let mut aliases: Vec<String> = POOLS
329        .get()
330        .expect("umbral: db pool not initialised — did you call App::build()?")
331        .keys()
332        .cloned()
333        .collect();
334    aliases.sort();
335    aliases
336}
337
338/// Open a new connection pool for the given database URL.
339///
340/// Dispatches on the URL scheme:
341///
342/// - `sqlite://...` or `sqlite::memory:` returns a
343///   [`DbPool::Sqlite`].
344/// - `postgres://...` / `postgresql://...` returns a
345///   [`DbPool::Postgres`].
346///
347/// Any other scheme surfaces as an `sqlx::Error::Configuration`.
348/// For callers that already have a typed pool, [`From`] impls on
349/// [`DbPool`] convert directly: `let dp: DbPool = sqlite_pool.into();`.
350pub async fn connect(url: &str) -> Result<DbPool, sqlx::Error> {
351    let scheme = url
352        .split("://")
353        .next()
354        .and_then(|s| s.split(':').next())
355        .unwrap_or(url);
356    match scheme {
357        "sqlite" => Ok(DbPool::Sqlite(connect_sqlite(url).await?)),
358        "postgres" | "postgresql" => Ok(DbPool::Postgres(connect_postgres(url).await?)),
359        other => Err(sqlx::Error::Configuration(
360            format!(
361                "umbral::db::connect: unsupported URL scheme `{other}://`. \
362                 Phase 1 supports `sqlite://` and `postgres://`."
363            )
364            .into(),
365        )),
366    }
367}
368
369/// Open a pool LAZILY from a URL — synchronous, connects on first use (audit_2
370/// H17). This is what `App::build()` uses to open the pools declared in
371/// `settings.databases`, which it can't do with the async [`connect`] because
372/// `build()` is a sync fn. Same backend dispatch and pool config as [`connect`].
373pub fn connect_lazy(url: &str) -> Result<DbPool, sqlx::Error> {
374    let scheme = url
375        .split("://")
376        .next()
377        .and_then(|s| s.split(':').next())
378        .unwrap_or(url);
379    match scheme {
380        "sqlite" => Ok(DbPool::Sqlite(connect_sqlite_lazy(url)?)),
381        "postgres" | "postgresql" => Ok(DbPool::Postgres(connect_postgres_lazy(url)?)),
382        other => Err(sqlx::Error::Configuration(
383            format!("umbral::db::connect_lazy: unsupported URL scheme `{other}://`.").into(),
384        )),
385    }
386}
387
388/// The effective pool configuration, resolved from [`crate::settings`]
389/// when installed and falling back to the documented production defaults
390/// otherwise (a pool can be opened before settings are installed). Shared
391/// by [`connect_postgres`] and [`connect_sqlite`] so both backends honour
392/// the same `UMBRAL_DB_*` knobs (gaps2 #91).
393struct PoolConfig {
394    max_connections: u32,
395    min_connections: u32,
396    acquire_timeout_secs: u64,
397    idle_timeout_secs: Option<u64>,
398    max_lifetime_secs: Option<u64>,
399    test_before_acquire: bool,
400}
401
402impl PoolConfig {
403    fn from_settings(s: &crate::settings::Settings) -> Self {
404        PoolConfig {
405            max_connections: s.db_max_connections,
406            min_connections: s.db_min_connections,
407            acquire_timeout_secs: s.db_acquire_timeout_secs,
408            idle_timeout_secs: s.db_idle_timeout_secs,
409            max_lifetime_secs: s.db_max_lifetime_secs,
410            test_before_acquire: s.db_test_before_acquire,
411        }
412    }
413
414    fn resolve() -> Self {
415        // Prefer the ambient settings once published by `App::build()`.
416        if let Some(s) = crate::settings::get_opt() {
417            return PoolConfig::from_settings(&s);
418        }
419        // audit_2 H16: the default pool is opened via `db::connect()` BEFORE
420        // `App::build()` in every documented boot path, so at this point the
421        // ambient settings aren't published yet. Re-read the `UMBRAL_DB_*` knobs
422        // straight from the environment (same figment parse `build()` uses) so
423        // an operator's `UMBRAL_DB_MAX_CONNECTIONS=100` isn't silently discarded
424        // for the pool that serves ALL traffic. Only if the env can't be parsed
425        // do we fall back to the hardcoded production defaults.
426        match crate::settings::Settings::from_env() {
427            Ok(s) => PoolConfig::from_settings(&s),
428            // Defaults mirror the `default_db_*` fns in `settings`.
429            Err(_) => PoolConfig {
430                max_connections: 10,
431                min_connections: 0,
432                acquire_timeout_secs: 30,
433                idle_timeout_secs: Some(600),
434                max_lifetime_secs: Some(1800),
435                test_before_acquire: true,
436            },
437        }
438    }
439
440    /// Emit one operator-facing line describing the pool that's about to
441    /// be built, so the effective config is visible in the boot log.
442    fn log(&self, backend: &str) {
443        tracing::info!(
444            backend,
445            max_connections = self.max_connections.max(1),
446            min_connections = self.min_connections,
447            acquire_timeout_secs = self.acquire_timeout_secs,
448            idle_timeout_secs = ?self.idle_timeout_secs,
449            max_lifetime_secs = ?self.max_lifetime_secs,
450            test_before_acquire = self.test_before_acquire,
451            "umbral: opening database pool"
452        );
453    }
454}
455
456/// Open a Postgres pool from a URL with umbral's pool configuration.
457///
458/// Set true the first time any request populates `RouteContext` session vars,
459/// so the Postgres `before_acquire` hook only pays the reset+set round-trips
460/// for apps that actually use them (RLS / per-connection GUCs). Apps that never
461/// set a session var never flip it → zero added overhead. audit_2 C2/R2.
462static SESSION_VARS_IN_USE: std::sync::atomic::AtomicBool =
463    std::sync::atomic::AtomicBool::new(false);
464
465/// PERF-5 / gaps2 #91: bare `PgPool::connect` uses sqlx's defaults with
466/// **no acquire timeout**, so a saturated pool blocks request tasks
467/// forever. We always apply the full set of pool knobs — `max_connections`,
468/// `min_connections`, a bounded `acquire_timeout` (fail fast),
469/// `idle_timeout`, `max_lifetime`, and `test_before_acquire` — read from
470/// [`crate::settings`] when available (falling back to the documented
471/// production defaults if the pool is opened before settings are
472/// installed). `idle_timeout`/`max_lifetime` are only applied when `Some`;
473/// a `None` (env `0`/empty) leaves that recycling disabled.
474pub async fn connect_postgres(url: &str) -> Result<PgPool, sqlx::Error> {
475    pg_pool_options().connect(url).await
476}
477
478/// Open a Postgres pool LAZILY (audit_2 H17): the pool object is created
479/// synchronously and connects on first use. This is what lets `App::build()`
480/// (a sync fn) open the pools declared in `settings.databases` without an async
481/// context. Same [`PoolConfig`] knobs and the same RLS `before_acquire` GUC
482/// hook as the eager [`connect_postgres`].
483pub fn connect_postgres_lazy(url: &str) -> Result<PgPool, sqlx::Error> {
484    Ok(pg_pool_options().connect_lazy(url)?)
485}
486
487/// Shared `PgPoolOptions` builder for the eager + lazy connect paths, so the
488/// pool knobs and the RLS session-var hook never drift between them.
489fn pg_pool_options() -> sqlx::postgres::PgPoolOptions {
490    use std::time::Duration;
491    let cfg = PoolConfig::resolve();
492    cfg.log("postgres");
493
494    let mut opts = sqlx::postgres::PgPoolOptions::new()
495        .max_connections(cfg.max_connections.max(1))
496        .min_connections(cfg.min_connections)
497        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
498        .test_before_acquire(cfg.test_before_acquire)
499        // audit_2 C2/R2 — apply the request's RouteContext session variables
500        // (GUCs) to the connection it's about to use, so RLS policies that read
501        // `current_setting('app.user_id')` see the right value. before_acquire
502        // runs inside the acquiring request's task, so the task-local
503        // RouteContext is visible. We RESET ALL first to clear any GUC a PRIOR
504        // request left on this pooled connection (the leak the audit calls out),
505        // then set the current request's. A process-wide flag keeps apps that
506        // never use session vars paying zero extra round-trips.
507        .before_acquire(|conn, _meta| {
508            Box::pin(async move {
509                let ctx = route_context::current();
510                let vars = ctx.session_vars();
511                if !vars.is_empty() {
512                    SESSION_VARS_IN_USE.store(true, std::sync::atomic::Ordering::Relaxed);
513                }
514                if SESSION_VARS_IN_USE.load(std::sync::atomic::Ordering::Relaxed) {
515                    sqlx::query("RESET ALL").execute(&mut *conn).await?;
516                    for (name, value) in vars {
517                        sqlx::query("SELECT set_config($1, $2, false)")
518                            .bind(name)
519                            .bind(value)
520                            .execute(&mut *conn)
521                            .await?;
522                    }
523                }
524                Ok(true)
525            })
526        });
527    if let Some(secs) = cfg.idle_timeout_secs {
528        opts = opts.idle_timeout(Duration::from_secs(secs));
529    }
530    if let Some(secs) = cfg.max_lifetime_secs {
531        opts = opts.max_lifetime(Duration::from_secs(secs));
532    }
533    opts
534}
535
536/// Open a SQLite-backed pool from a URL.
537///
538/// Applies the standard production PRAGMAs to every connection in the
539/// pool: WAL journal, NORMAL synchronous, a 5-second busy-timeout, and
540/// foreign-key enforcement on. Without these, a fresh `SqlitePool` ends
541/// up in `journal_mode = DELETE` + `synchronous = FULL` — the safe
542/// SQLite defaults that cost ~1-4 seconds per concurrent INSERT once
543/// any other connection touches the file (the rollback-journal lock
544/// serialises writers).
545///
546/// | PRAGMA | Value | Why |
547/// |---|---|---|
548/// | `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. |
549/// | `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". |
550/// | `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. |
551/// | `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. |
552///
553/// **In-memory URLs are backed by a process-unique temp file.** A bare
554/// `sqlite::memory:` gives every connection in the pool its OWN private,
555/// empty database, so a table created on one connection is invisible to a
556/// query that lands on another — and a shared in-memory database doesn't
557/// survive the connection (or the tokio runtime) that created it being
558/// dropped. Both surface as a flaky "no such table" whenever a pool is
559/// reused across queries or test cases. Routing in-memory URLs through a
560/// small temp file (which every connection sees and which persists for the
561/// process) sidesteps both — the same approach `umbral-testing::TempPool`
562/// already documents. File-backed (`sqlite://app.db`) and Postgres URLs are
563/// untouched.
564pub async fn connect_sqlite(url: &str) -> Result<SqlitePool, sqlx::Error> {
565    let (pool_opts, opts) = sqlite_options(url)?;
566    pool_opts.connect_with(opts).await
567}
568
569/// Open a SQLite pool LAZILY (audit_2 H17): the pool is created synchronously
570/// and connects on first use, so `App::build()` can open `settings.databases`
571/// entries without an async context. Same PRAGMAs and [`PoolConfig`] knobs as
572/// the eager [`connect_sqlite`].
573pub fn connect_sqlite_lazy(url: &str) -> Result<SqlitePool, sqlx::Error> {
574    let (pool_opts, opts) = sqlite_options(url)?;
575    Ok(pool_opts.connect_lazy_with(opts))
576}
577
578/// Shared SQLite options builder (pool knobs + connection PRAGMAs + in-memory
579/// temp-file handling) for the eager + lazy connect paths, so they never drift.
580fn sqlite_options(url: &str) -> Result<(SqlitePoolOptions, SqliteConnectOptions), sqlx::Error> {
581    use std::sync::atomic::{AtomicU64, Ordering};
582    static MEM_SEQ: AtomicU64 = AtomicU64::new(0);
583
584    let lower = url.to_ascii_lowercase();
585    let in_memory = lower.contains(":memory:") || lower.contains("mode=memory");
586
587    let opts = if in_memory {
588        let n = MEM_SEQ.fetch_add(1, Ordering::Relaxed);
589        let path =
590            std::env::temp_dir().join(format!("umbral_mem_{}_{n}.sqlite", std::process::id()));
591        // Best-effort: remove a stale file from a previous run with this
592        // exact (pid, seq) — pids recycle. WAL/SHM siblings are recreated.
593        let _ = std::fs::remove_file(&path);
594        SqliteConnectOptions::new()
595            .filename(&path)
596            .create_if_missing(true)
597    } else {
598        SqliteConnectOptions::from_str(url)?
599    };
600    let opts = opts
601        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
602        .synchronous(SqliteSynchronous::Normal)
603        .busy_timeout(Duration::from_secs(5))
604        .foreign_keys(true)
605        // Disable per-statement logging — sqlx's default INFO-level
606        // logger reads every statement before execution, which adds a
607        // measurable per-query overhead under load. The `slow statement`
608        // WARN at the 1-second threshold stays on, since it goes via a
609        // separate log target.
610        .log_statements(tracing::log::LevelFilter::Off);
611
612    // gaps2 #91: apply the same settings-driven pool knobs as Postgres so
613    // a single `UMBRAL_DB_*` configuration governs every backend. SQLite is
614    // effectively single-writer (WAL serialises writers behind one lock),
615    // so a large `max_connections` mainly buys concurrent *readers*; the
616    // knob is still honoured rather than hardcoding a divergent SQLite path.
617    let cfg = PoolConfig::resolve();
618    cfg.log("sqlite");
619    let mut pool_opts = SqlitePoolOptions::new()
620        .max_connections(cfg.max_connections.max(1))
621        .min_connections(cfg.min_connections)
622        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
623        .test_before_acquire(cfg.test_before_acquire);
624    if let Some(secs) = cfg.idle_timeout_secs {
625        pool_opts = pool_opts.idle_timeout(Duration::from_secs(secs));
626    }
627    if let Some(secs) = cfg.max_lifetime_secs {
628        pool_opts = pool_opts.max_lifetime(Duration::from_secs(secs));
629    }
630    Ok((pool_opts, opts))
631}
632
633/// Gracefully close the ambient default database pool (gaps2 #91).
634///
635/// Call this once during shutdown — after the HTTP server has stopped
636/// accepting connections — to let sqlx flush in-flight work and close
637/// every pooled connection cleanly rather than having them dropped
638/// abruptly when the process exits. For SQLite this also lets WAL
639/// checkpoint; for Postgres it sends a clean `Terminate` so the server
640/// doesn't log the connections as unexpectedly lost.
641///
642/// Closing is terminal: the ambient [`OnceLock`] is left in place (it
643/// can't be unset), so the pool object remains registered but is closed.
644/// Acquiring from a closed pool errors, which is the intended post-
645/// shutdown behaviour. A no-op if no pool was ever registered.
646///
647/// ```rust,ignore
648/// // in your shutdown handler, after the server stops:
649/// umbral::db::close().await;
650/// ```
651pub async fn close() {
652    if let Some(pools) = POOLS.get() {
653        for db in pools.values() {
654            match db {
655                DbPool::Sqlite(p) => p.close().await,
656                DbPool::Postgres(p) => p.close().await,
657            }
658        }
659    }
660}
661
662// =============================================================================
663// Transaction support
664// =============================================================================
665
666/// An active database transaction, typed by backend.
667///
668/// `Transaction` wraps either a `sqlx::Transaction<'static, sqlx::Sqlite>` or
669/// a `sqlx::Transaction<'static, sqlx::Postgres>` and provides the executor
670/// surface needed by the ORM's query terminals.
671///
672/// ## How to obtain one
673///
674/// The typical path is through the top-level closure helpers:
675///
676/// ```rust,ignore
677/// use umbral::db::transaction;
678///
679/// let order = transaction(|tx| async move {
680///     let o = Order::objects().on_tx(tx).create(new_order).await?;
681///     Inventory::objects().on_tx(tx).filter(...).update_values(...).await?;
682///     Ok::<_, MyError>(o)
683/// }).await?;
684/// ```
685///
686/// For manual control (committing or rolling back yourself) call
687/// [`begin`] / [`begin_sqlite`] / [`begin_pg`] directly.
688///
689/// ## Executor contract
690///
691/// The `as_sqlite_mut` / `as_pg_mut` accessors return a mutable reference to
692/// the underlying sqlx transaction so ORM internals can call
693/// `sqlx::query(...).execute(&mut *inner)`. Both the `QuerySet::on_tx` and
694/// `Manager::create_in_tx` methods receive `&mut Transaction` and dispatch
695/// through these accessors.
696pub struct Transaction {
697    inner: TransactionInner,
698}
699
700enum TransactionInner {
701    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
702    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
703}
704
705impl Transaction {
706    /// Return a mutable reference to the inner SQLite transaction, or `None`
707    /// when this is a Postgres transaction.
708    pub fn as_sqlite_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Sqlite>> {
709        match &mut self.inner {
710            TransactionInner::Sqlite(tx) => Some(tx),
711            TransactionInner::Postgres(_) => None,
712        }
713    }
714
715    /// Return a mutable reference to the inner Postgres transaction, or `None`
716    /// when this is a SQLite transaction.
717    pub fn as_pg_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Postgres>> {
718        match &mut self.inner {
719            TransactionInner::Sqlite(_) => None,
720            TransactionInner::Postgres(tx) => Some(tx),
721        }
722    }
723
724    /// The backend name — `"sqlite"` or `"postgres"`. Mirrors
725    /// [`DbPool::backend_name`] so shared dispatch helpers can use the same
726    /// match arm.
727    pub fn backend_name(&self) -> &'static str {
728        match &self.inner {
729            TransactionInner::Sqlite(_) => "sqlite",
730            TransactionInner::Postgres(_) => "postgres",
731        }
732    }
733
734    /// Commit the transaction explicitly.
735    ///
736    /// The closure-based helpers ([`transaction`] / [`transaction_sqlite`] /
737    /// [`transaction_pg`]) call this automatically on `Ok`. Use this only
738    /// when you obtained the transaction via [`begin`] / [`begin_sqlite`] /
739    /// [`begin_pg`] and are driving the lifecycle yourself.
740    pub async fn commit(self) -> Result<(), sqlx::Error> {
741        match self.inner {
742            TransactionInner::Sqlite(tx) => tx.commit().await,
743            TransactionInner::Postgres(tx) => tx.commit().await,
744        }
745    }
746
747    /// Roll back the transaction explicitly.
748    ///
749    /// The closure-based helpers call this automatically on `Err`. Use this
750    /// only in the manual-control pattern.
751    pub async fn rollback(self) -> Result<(), sqlx::Error> {
752        match self.inner {
753            TransactionInner::Sqlite(tx) => tx.rollback().await,
754            TransactionInner::Postgres(tx) => tx.rollback().await,
755        }
756    }
757}
758
759/// Begin a transaction against the ambient pool.
760///
761/// The `Transaction` is dropped-and-rolled-back if neither `commit` nor
762/// `rollback` is called before it goes out of scope (sqlx's drop impl).
763/// Most callers use the higher-level [`transaction`] / [`transaction_sqlite`]
764/// / [`transaction_pg`] closures instead.
765///
766/// # Panics
767///
768/// Panics if `App::build()` hasn't run.
769pub async fn begin() -> Result<Transaction, sqlx::Error> {
770    match pool_dispatched() {
771        DbPool::Sqlite(pool) => {
772            // `BEGIN IMMEDIATE`: acquire the write lock at BEGIN so a contending
773            // writer WAITS (busy_timeout) instead of hitting the deferred-upgrade
774            // SQLITE_BUSY (SQLite skips the busy handler for a read→write upgrade
775            // to avoid deadlock). Postgres keeps the default (deferred) begin.
776            let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
777            Ok(Transaction {
778                inner: TransactionInner::Sqlite(tx),
779            })
780        }
781        DbPool::Postgres(pool) => {
782            let tx = pool.begin().await?;
783            Ok(Transaction {
784                inner: TransactionInner::Postgres(tx),
785            })
786        }
787    }
788}
789
790/// Begin a transaction against the pool registered under `alias` (audit_2
791/// core-app-config #5).
792///
793/// [`begin`] / [`transaction`] always target the `"default"` pool — they
794/// consult neither the [`DatabaseRouter`] nor per-model aliases nor the tenant
795/// route context. In a multi-DB or DB-per-tenant app, a model routed to a
796/// replica/tenant alias run inside a plain `transaction()` would execute its
797/// SQL on the DEFAULT database — a silent wrong-database write. Use this to
798/// pin the transaction to the intended pool; `Model::objects().on_tx(&mut tx)`
799/// then runs every statement on `alias`'s pool regardless of the model's own
800/// routing.
801///
802/// # Panics
803///
804/// Panics if `App::build()` hasn't run, or if no pool is registered under
805/// `alias` (same contract as [`pool_for_dispatched`]).
806pub async fn begin_for(alias: &str) -> Result<Transaction, sqlx::Error> {
807    match pool_for_dispatched(alias) {
808        DbPool::Sqlite(pool) => Ok(Transaction {
809            // BEGIN IMMEDIATE for SQLite — see `begin()`.
810            inner: TransactionInner::Sqlite(pool.begin_with("BEGIN IMMEDIATE").await?),
811        }),
812        DbPool::Postgres(pool) => Ok(Transaction {
813            inner: TransactionInner::Postgres(pool.begin().await?),
814        }),
815    }
816}
817
818/// Begin a transaction against an explicit SQLite pool.
819pub async fn begin_sqlite(pool: &sqlx::SqlitePool) -> Result<Transaction, sqlx::Error> {
820    // BEGIN IMMEDIATE for SQLite — see `begin()`.
821    let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
822    Ok(Transaction {
823        inner: TransactionInner::Sqlite(tx),
824    })
825}
826
827/// Begin a transaction against an explicit Postgres pool.
828pub async fn begin_pg(pool: &sqlx::PgPool) -> Result<Transaction, sqlx::Error> {
829    let tx = pool.begin().await?;
830    Ok(Transaction {
831        inner: TransactionInner::Postgres(tx),
832    })
833}
834
835/// Pinned, boxed `Future` with a lifetime parameter.
836///
837/// This is the required shape for the closure argument to
838/// [`transaction`] / [`transaction_sqlite`] / [`transaction_pg`].
839/// The lifetime `'a` ties the future to the `&'a mut Transaction`
840/// reference so the borrow checker can verify that the transaction
841/// outlives the async work being done inside it.
842///
843/// Call sites construct this by calling `.boxed()` or wrapping the
844/// `async move` block:
845///
846/// ```rust,ignore
847/// use futures::FutureExt;
848/// use umbral::db::{transaction, TxFuture};
849///
850/// transaction(|tx| {
851///     Box::pin(async move {
852///         Post::objects().on_tx(tx).create(new_post).await?;
853///         Ok::<_, MyError>(())
854///     })
855/// }).await?;
856/// ```
857///
858/// The `async move { ... }` block captures the `&mut Transaction` by
859/// move and the `Box::pin(...)` wrapper satisfies the HRTB bound.
860pub type TxFuture<'a, T, E> = Pin<Box<dyn std::future::Future<Output = Result<T, E>> + Send + 'a>>;
861
862/// Run an async closure inside a database transaction against the ambient pool.
863///
864/// The closure receives `&mut Transaction`. On `Ok` the transaction is
865/// committed; on `Err` it is rolled back. Returns the closure's `Ok` value
866/// on success.
867///
868/// The closure must return a `TxFuture` (a `Pin<Box<dyn Future>>`).
869/// Use `Box::pin(async move { ... })`:
870///
871/// ```rust,ignore
872/// use umbral::db::transaction;
873///
874/// let order = transaction(|tx| Box::pin(async move {
875///     let o = Order::objects().on_tx(tx).create(new_order).await?;
876///     Inventory::objects()
877///         .on_tx(tx)
878///         .filter(inv::PRODUCT_ID.eq(sku))
879///         .update_values(delta)
880///         .await?;
881///     Ok::<_, MyError>(o)
882/// })).await?;
883/// ```
884///
885/// # Panics
886///
887/// Panics if `App::build()` hasn't run.
888pub async fn transaction<F, T, E>(f: F) -> Result<T, E>
889where
890    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
891    E: From<sqlx::Error>,
892{
893    let mut tx = begin().await.map_err(E::from)?;
894    match f(&mut tx).await {
895        Ok(val) => {
896            tx.commit().await.map_err(E::from)?;
897            Ok(val)
898        }
899        Err(e) => {
900            // Best-effort rollback — if it fails we surface the original error.
901            let _ = tx.rollback().await;
902            Err(e)
903        }
904    }
905}
906
907/// Run an async closure inside a transaction against the pool registered under
908/// `alias` (audit_2 core-app-config #5) — the alias-aware sibling of
909/// [`transaction`]. Use this for a multi-DB / DB-per-tenant app so the
910/// transaction (and every `on_tx` statement inside it) runs on the RIGHT
911/// database instead of silently on `"default"`. See [`begin_for`] for the
912/// routing rationale and panics.
913///
914/// ```rust,ignore
915/// use umbral::db::transaction_on;
916///
917/// transaction_on("replica_writes", |tx| Box::pin(async move {
918///     Ledger::objects().on_tx(tx).create(entry).await?;
919///     Ok::<_, MyError>(())
920/// })).await?;
921/// ```
922pub async fn transaction_on<F, T, E>(alias: &str, f: F) -> Result<T, E>
923where
924    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
925    E: From<sqlx::Error>,
926{
927    let mut tx = begin_for(alias).await.map_err(E::from)?;
928    match f(&mut tx).await {
929        Ok(val) => {
930            tx.commit().await.map_err(E::from)?;
931            Ok(val)
932        }
933        Err(e) => {
934            // Best-effort rollback — if it fails we surface the original error.
935            let _ = tx.rollback().await;
936            Err(e)
937        }
938    }
939}
940
941/// Run an async closure inside a SQLite transaction against an explicit pool.
942///
943/// The SQLite-specific variant of [`transaction`] for callers that want to
944/// pin to SQLite regardless of what the ambient pool is, or that are running
945/// outside of `App::build()` (e.g. tests).
946///
947/// See [`transaction`] for the closure shape.
948pub async fn transaction_sqlite<F, T, E>(pool: &sqlx::SqlitePool, f: F) -> Result<T, E>
949where
950    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
951    E: From<sqlx::Error>,
952{
953    let mut tx = begin_sqlite(pool).await.map_err(E::from)?;
954    match f(&mut tx).await {
955        Ok(val) => {
956            tx.commit().await.map_err(E::from)?;
957            Ok(val)
958        }
959        Err(e) => {
960            let _ = tx.rollback().await;
961            Err(e)
962        }
963    }
964}
965
966/// Run an async closure inside a Postgres transaction against an explicit pool.
967///
968/// The Postgres-specific variant of [`transaction`] for callers that want to
969/// pin to Postgres or run outside `App::build()`.
970///
971/// See [`transaction`] for the closure shape.
972pub async fn transaction_pg<F, T, E>(pool: &sqlx::PgPool, f: F) -> Result<T, E>
973where
974    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
975    E: From<sqlx::Error>,
976{
977    let mut tx = begin_pg(pool).await.map_err(E::from)?;
978    match f(&mut tx).await {
979        Ok(val) => {
980            tx.commit().await.map_err(E::from)?;
981            Ok(val)
982        }
983        Err(e) => {
984            let _ = tx.rollback().await;
985            Err(e)
986        }
987    }
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    // `pool` and `pool_for` read the process-wide `POOLS` `OnceLock`, which
995    // can only be set once per process. Under cargo test's parallel runner
996    // that makes them unreliable to cover directly without `serial_test` or
997    // a refactor, so they're intentionally out of scope here. Same reason
998    // the "pool() panics before init" path isn't exercised: another test in
999    // the same process may have already populated the lock.
1000    //
1001    // Mirrors the settings module's stance on its own `init`/`get` pair.
1002
1003    /// `connect` hands back a SQLite pool wrapped in `DbPool::Sqlite` we
1004    /// can actually run queries through.
1005    #[tokio::test]
1006    async fn connect_returns_a_working_pool_against_in_memory_sqlite() {
1007        let pool = connect("sqlite::memory:")
1008            .await
1009            .expect("in-memory sqlite should always connect");
1010
1011        let sqlite = pool.as_sqlite().expect("should be Sqlite variant");
1012        let (one,): (i64,) = sqlx::query_as("SELECT 1")
1013            .fetch_one(sqlite)
1014            .await
1015            .expect("SELECT 1 should succeed on a fresh pool");
1016
1017        assert_eq!(one, 1);
1018    }
1019
1020    /// A URL sqlx can't parse surfaces as a plain `sqlx::Error`. We don't
1021    /// pin the variant — the family is the contract.
1022    #[tokio::test]
1023    async fn connect_errors_on_malformed_url() {
1024        let result = connect("not-a-real-url").await;
1025        assert!(
1026            result.is_err(),
1027            "expected sqlx to reject a malformed url, got Ok"
1028        );
1029    }
1030
1031    /// MySQL and similar schemes that umbral hasn't shipped yet
1032    /// surface as a clear configuration error rather than a
1033    /// driver-internal one.
1034    #[tokio::test]
1035    async fn connect_rejects_unsupported_scheme() {
1036        let result = connect("mysql://user:pass@host/db").await;
1037        match result {
1038            Err(sqlx::Error::Configuration(msg)) => {
1039                assert!(msg.to_string().contains("mysql"));
1040            }
1041            other => panic!("expected Configuration error, got {other:?}"),
1042        }
1043    }
1044
1045    /// `From<SqlitePool>` and the variant accessors round-trip.
1046    #[tokio::test]
1047    async fn sqlite_pool_round_trips_through_dbpool() {
1048        let sp = SqlitePool::connect("sqlite::memory:").await.unwrap();
1049        let dp: DbPool = sp.clone().into();
1050        assert_eq!(dp.backend_name(), "sqlite");
1051        assert!(dp.as_sqlite().is_some());
1052        assert!(dp.as_postgres().is_none());
1053    }
1054}