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/// gaps4 #16: every GUC name umbra has ever set via a `RouteContext` session
527/// var. On connection checkout we reset only THESE (the ones the current
528/// request isn't re-setting), instead of `RESET ALL`. `RESET ALL` also wiped
529/// app- and operator-managed GUCs (`ALTER ROLE/DATABASE SET`, anything the app
530/// set at connect time) on every acquire — collateral damage. Scoping the reset
531/// to umbra's own names keeps tenant/RLS isolation intact (a stale
532/// `app.tenant` from a prior request on the pooled connection is still cleared)
533/// while leaving everything else alone.
534static UMBRAL_GUC_NAMES: std::sync::Mutex<Option<std::collections::HashSet<String>>> =
535    std::sync::Mutex::new(None);
536
537/// A GUC name is safe to interpolate into `RESET <name>` (sqlx can't bind an
538/// identifier). Names come from framework code, never user input, but validate
539/// anyway: letters/digits/underscore, one optional `namespace.` prefix.
540fn is_valid_guc_name(name: &str) -> bool {
541    fn ident(s: &str) -> bool {
542        !s.is_empty()
543            && s.bytes()
544                .next()
545                .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
546            && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
547    }
548    match name.split_once('.') {
549        Some((ns, key)) => ident(ns) && ident(key),
550        None => ident(name),
551    }
552}
553
554/// PERF-5 / gaps2 #91: bare `PgPool::connect` uses sqlx's defaults with
555/// **no acquire timeout**, so a saturated pool blocks request tasks
556/// forever. We always apply the full set of pool knobs — `max_connections`,
557/// `min_connections`, a bounded `acquire_timeout` (fail fast),
558/// `idle_timeout`, `max_lifetime`, and `test_before_acquire` — read from
559/// [`crate::settings`] when available (falling back to the documented
560/// production defaults if the pool is opened before settings are
561/// installed). `idle_timeout`/`max_lifetime` are only applied when `Some`;
562/// a `None` (env `0`/empty) leaves that recycling disabled.
563pub async fn connect_postgres(url: &str) -> Result<PgPool, sqlx::Error> {
564    pg_pool_options().connect(url).await
565}
566
567/// Open a Postgres pool LAZILY (audit_2 H17): the pool object is created
568/// synchronously and connects on first use. This is what lets `App::build()`
569/// (a sync fn) open the pools declared in `settings.databases` without an async
570/// context. Same [`PoolConfig`] knobs and the same RLS `before_acquire` GUC
571/// hook as the eager [`connect_postgres`].
572pub fn connect_postgres_lazy(url: &str) -> Result<PgPool, sqlx::Error> {
573    pg_pool_options().connect_lazy(url)
574}
575
576/// Shared `PgPoolOptions` builder for the eager + lazy connect paths, so the
577/// pool knobs and the RLS session-var hook never drift between them.
578fn pg_pool_options() -> sqlx::postgres::PgPoolOptions {
579    use std::time::Duration;
580    let cfg = PoolConfig::resolve();
581    cfg.log("postgres");
582
583    let mut opts = sqlx::postgres::PgPoolOptions::new()
584        .max_connections(cfg.max_connections.max(1))
585        .min_connections(cfg.min_connections)
586        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
587        .test_before_acquire(cfg.test_before_acquire)
588        // audit_2 C2/R2 — apply the request's RouteContext session variables
589        // (GUCs) to the connection it's about to use, so RLS policies that read
590        // `current_setting('app.user_id')` see the right value. before_acquire
591        // runs inside the acquiring request's task, so the task-local
592        // RouteContext is visible. We RESET ALL first to clear any GUC a PRIOR
593        // request left on this pooled connection (the leak the audit calls out),
594        // then set the current request's. A process-wide flag keeps apps that
595        // never use session vars paying zero extra round-trips.
596        .before_acquire(|conn, _meta| {
597            Box::pin(async move {
598                let ctx = route_context::current();
599                let vars = ctx.session_vars();
600                if !vars.is_empty() {
601                    SESSION_VARS_IN_USE.store(true, std::sync::atomic::Ordering::Release);
602                }
603                // review_3: this flag gates whether a pooled connection's leaked
604                // GUCs get cleared before reuse — a cross-tenant RLS leak if a
605                // request skips the reset. Acquire/Release (not Relaxed) gives the
606                // happens-before edge so the `true` a prior request stored is
607                // always observed by the next acquisition on a weakly-ordered CPU.
608                if SESSION_VARS_IN_USE.load(std::sync::atomic::Ordering::Acquire) {
609                    // gaps4 #16: clear only umbra's OWN stale GUCs — the names a
610                    // PRIOR request set on this pooled connection that THIS
611                    // request isn't re-setting — instead of `RESET ALL` (which
612                    // also wiped app/operator-managed GUCs). In the common case
613                    // (every request sets the same names) this resets nothing:
614                    // the `set_config` below overwrites them.
615                    let current: std::collections::HashSet<&str> =
616                        vars.iter().map(|(n, _)| n.as_str()).collect();
617                    let stale: Vec<String> = {
618                        let mut guard = UMBRAL_GUC_NAMES.lock().unwrap();
619                        let seen = guard.get_or_insert_with(std::collections::HashSet::new);
620                        let stale = seen
621                            .iter()
622                            .filter(|n| !current.contains(n.as_str()))
623                            .cloned()
624                            .collect::<Vec<_>>();
625                        for (name, _) in vars {
626                            seen.insert(name.clone());
627                        }
628                        stale
629                    };
630                    for name in stale {
631                        // Identifier can't be bound; validated framework-owned name.
632                        if is_valid_guc_name(&name) {
633                            sqlx::query(&format!("RESET {name}"))
634                                .execute(&mut *conn)
635                                .await?;
636                        }
637                    }
638                    for (name, value) in vars {
639                        sqlx::query("SELECT set_config($1, $2, false)")
640                            .bind(name)
641                            .bind(value)
642                            .execute(&mut *conn)
643                            .await?;
644                    }
645                }
646                Ok(true)
647            })
648        });
649    if let Some(secs) = cfg.idle_timeout_secs {
650        opts = opts.idle_timeout(Duration::from_secs(secs));
651    }
652    if let Some(secs) = cfg.max_lifetime_secs {
653        opts = opts.max_lifetime(Duration::from_secs(secs));
654    }
655    opts
656}
657
658/// Open a SQLite-backed pool from a URL.
659///
660/// Applies the standard production PRAGMAs to every connection in the
661/// pool: WAL journal, NORMAL synchronous, a 5-second busy-timeout, and
662/// foreign-key enforcement on. Without these, a fresh `SqlitePool` ends
663/// up in `journal_mode = DELETE` + `synchronous = FULL` — the safe
664/// SQLite defaults that cost ~1-4 seconds per concurrent INSERT once
665/// any other connection touches the file (the rollback-journal lock
666/// serialises writers).
667///
668/// | PRAGMA | Value | Why |
669/// |---|---|---|
670/// | `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. |
671/// | `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". |
672/// | `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. |
673/// | `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. |
674///
675/// **In-memory URLs are backed by a process-unique temp file.** A bare
676/// `sqlite::memory:` gives every connection in the pool its OWN private,
677/// empty database, so a table created on one connection is invisible to a
678/// query that lands on another — and a shared in-memory database doesn't
679/// survive the connection (or the tokio runtime) that created it being
680/// dropped. Both surface as a flaky "no such table" whenever a pool is
681/// reused across queries or test cases. Routing in-memory URLs through a
682/// small temp file (which every connection sees and which persists for the
683/// process) sidesteps both — the same approach `umbral-testing::TempPool`
684/// already documents. File-backed (`sqlite://app.db`) and Postgres URLs are
685/// untouched.
686pub async fn connect_sqlite(url: &str) -> Result<SqlitePool, sqlx::Error> {
687    let (pool_opts, opts) = sqlite_options(url)?;
688    pool_opts.connect_with(opts).await
689}
690
691/// Open a SQLite pool LAZILY (audit_2 H17): the pool is created synchronously
692/// and connects on first use, so `App::build()` can open `settings.databases`
693/// entries without an async context. Same PRAGMAs and [`PoolConfig`] knobs as
694/// the eager [`connect_sqlite`].
695pub fn connect_sqlite_lazy(url: &str) -> Result<SqlitePool, sqlx::Error> {
696    let (pool_opts, opts) = sqlite_options(url)?;
697    Ok(pool_opts.connect_lazy_with(opts))
698}
699
700/// Shared SQLite options builder (pool knobs + connection PRAGMAs + in-memory
701/// temp-file handling) for the eager + lazy connect paths, so they never drift.
702fn sqlite_options(url: &str) -> Result<(SqlitePoolOptions, SqliteConnectOptions), sqlx::Error> {
703    use std::sync::atomic::{AtomicU64, Ordering};
704    static MEM_SEQ: AtomicU64 = AtomicU64::new(0);
705
706    let lower = url.to_ascii_lowercase();
707    let in_memory = lower.contains(":memory:") || lower.contains("mode=memory");
708
709    let opts = if in_memory {
710        let n = MEM_SEQ.fetch_add(1, Ordering::Relaxed);
711        let path =
712            std::env::temp_dir().join(format!("umbral_mem_{}_{n}.sqlite", std::process::id()));
713        // Best-effort: remove a stale file from a previous run with this
714        // exact (pid, seq) — pids recycle. WAL/SHM siblings are recreated.
715        let _ = std::fs::remove_file(&path);
716        SqliteConnectOptions::new()
717            .filename(&path)
718            .create_if_missing(true)
719    } else {
720        SqliteConnectOptions::from_str(url)?
721    };
722    let opts = opts
723        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
724        .synchronous(SqliteSynchronous::Normal)
725        .busy_timeout(Duration::from_secs(5))
726        .foreign_keys(true)
727        // Disable per-statement logging — sqlx's default INFO-level
728        // logger reads every statement before execution, which adds a
729        // measurable per-query overhead under load. The `slow statement`
730        // WARN at the 1-second threshold stays on, since it goes via a
731        // separate log target.
732        .log_statements(tracing::log::LevelFilter::Off);
733
734    // gaps2 #91: apply the same settings-driven pool knobs as Postgres so
735    // a single `UMBRAL_DB_*` configuration governs every backend. SQLite is
736    // effectively single-writer (WAL serialises writers behind one lock),
737    // so a large `max_connections` mainly buys concurrent *readers*; the
738    // knob is still honoured rather than hardcoding a divergent SQLite path.
739    let cfg = PoolConfig::resolve();
740    cfg.log("sqlite");
741    let mut pool_opts = SqlitePoolOptions::new()
742        .max_connections(cfg.max_connections.max(1))
743        .min_connections(cfg.min_connections)
744        .acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
745        .test_before_acquire(cfg.test_before_acquire);
746    if let Some(secs) = cfg.idle_timeout_secs {
747        pool_opts = pool_opts.idle_timeout(Duration::from_secs(secs));
748    }
749    if let Some(secs) = cfg.max_lifetime_secs {
750        pool_opts = pool_opts.max_lifetime(Duration::from_secs(secs));
751    }
752    Ok((pool_opts, opts))
753}
754
755/// Gracefully close the ambient default database pool (gaps2 #91).
756///
757/// Call this once during shutdown — after the HTTP server has stopped
758/// accepting connections — to let sqlx flush in-flight work and close
759/// every pooled connection cleanly rather than having them dropped
760/// abruptly when the process exits. For SQLite this also lets WAL
761/// checkpoint; for Postgres it sends a clean `Terminate` so the server
762/// doesn't log the connections as unexpectedly lost.
763///
764/// Closing is terminal: the ambient [`OnceLock`] is left in place (it
765/// can't be unset), so the pool object remains registered but is closed.
766/// Acquiring from a closed pool errors, which is the intended post-
767/// shutdown behaviour. A no-op if no pool was ever registered.
768///
769/// ```rust,ignore
770/// // in your shutdown handler, after the server stops:
771/// umbral::db::close().await;
772/// ```
773pub async fn close() {
774    if let Some(pools) = POOLS.get() {
775        for db in pools.values() {
776            match db {
777                DbPool::Sqlite(p) => p.close().await,
778                DbPool::Postgres(p) => p.close().await,
779            }
780        }
781    }
782}
783
784// =============================================================================
785// Transaction support
786// =============================================================================
787
788/// An active database transaction, typed by backend.
789///
790/// `Transaction` wraps either a `sqlx::Transaction<'static, sqlx::Sqlite>` or
791/// a `sqlx::Transaction<'static, sqlx::Postgres>` and provides the executor
792/// surface needed by the ORM's query terminals.
793///
794/// ## How to obtain one
795///
796/// The typical path is through the top-level closure helpers:
797///
798/// ```rust,ignore
799/// use umbral::db::transaction;
800///
801/// let order = transaction(|tx| async move {
802///     let o = Order::objects().on_tx(tx).create(new_order).await?;
803///     Inventory::objects().on_tx(tx).filter(...).update_values(...).await?;
804///     Ok::<_, MyError>(o)
805/// }).await?;
806/// ```
807///
808/// For manual control (committing or rolling back yourself) call
809/// [`begin`] / [`begin_sqlite`] / [`begin_pg`] directly.
810///
811/// ## Executor contract
812///
813/// The `as_sqlite_mut` / `as_pg_mut` accessors return a mutable reference to
814/// the underlying sqlx transaction so ORM internals can call
815/// `sqlx::query(...).execute(&mut *inner)`. Both the `QuerySet::on_tx` and
816/// `Manager::create_in_tx` methods receive `&mut Transaction` and dispatch
817/// through these accessors.
818pub struct Transaction {
819    inner: TransactionInner,
820}
821
822enum TransactionInner {
823    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
824    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
825}
826
827impl Transaction {
828    /// Return a mutable reference to the inner SQLite transaction, or `None`
829    /// when this is a Postgres transaction.
830    pub fn as_sqlite_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Sqlite>> {
831        match &mut self.inner {
832            TransactionInner::Sqlite(tx) => Some(tx),
833            TransactionInner::Postgres(_) => None,
834        }
835    }
836
837    /// Return a mutable reference to the inner Postgres transaction, or `None`
838    /// when this is a SQLite transaction.
839    pub fn as_pg_mut(&mut self) -> Option<&mut sqlx::Transaction<'static, sqlx::Postgres>> {
840        match &mut self.inner {
841            TransactionInner::Sqlite(_) => None,
842            TransactionInner::Postgres(tx) => Some(tx),
843        }
844    }
845
846    /// The backend name — `"sqlite"` or `"postgres"`. Mirrors
847    /// [`DbPool::backend_name`] so shared dispatch helpers can use the same
848    /// match arm.
849    pub fn backend_name(&self) -> &'static str {
850        match &self.inner {
851            TransactionInner::Sqlite(_) => "sqlite",
852            TransactionInner::Postgres(_) => "postgres",
853        }
854    }
855
856    /// Commit the transaction explicitly.
857    ///
858    /// The closure-based helpers ([`transaction`] / [`transaction_sqlite`] /
859    /// [`transaction_pg`]) call this automatically on `Ok`. Use this only
860    /// when you obtained the transaction via [`begin`] / [`begin_sqlite`] /
861    /// [`begin_pg`] and are driving the lifecycle yourself.
862    pub async fn commit(self) -> Result<(), sqlx::Error> {
863        match self.inner {
864            TransactionInner::Sqlite(tx) => tx.commit().await,
865            TransactionInner::Postgres(tx) => tx.commit().await,
866        }
867    }
868
869    /// Roll back the transaction explicitly.
870    ///
871    /// The closure-based helpers call this automatically on `Err`. Use this
872    /// only in the manual-control pattern.
873    pub async fn rollback(self) -> Result<(), sqlx::Error> {
874        match self.inner {
875            TransactionInner::Sqlite(tx) => tx.rollback().await,
876            TransactionInner::Postgres(tx) => tx.rollback().await,
877        }
878    }
879}
880
881/// Begin a transaction against the ambient pool.
882///
883/// The `Transaction` is dropped-and-rolled-back if neither `commit` nor
884/// `rollback` is called before it goes out of scope (sqlx's drop impl).
885/// Most callers use the higher-level [`transaction`] / [`transaction_sqlite`]
886/// / [`transaction_pg`] closures instead.
887///
888/// # Panics
889///
890/// Panics if `App::build()` hasn't run.
891pub async fn begin() -> Result<Transaction, sqlx::Error> {
892    match pool_dispatched() {
893        DbPool::Sqlite(pool) => {
894            // `BEGIN IMMEDIATE`: acquire the write lock at BEGIN so a contending
895            // writer WAITS (busy_timeout) instead of hitting the deferred-upgrade
896            // SQLITE_BUSY (SQLite skips the busy handler for a read→write upgrade
897            // to avoid deadlock). Postgres keeps the default (deferred) begin.
898            let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
899            Ok(Transaction {
900                inner: TransactionInner::Sqlite(tx),
901            })
902        }
903        DbPool::Postgres(pool) => {
904            let tx = pool.begin().await?;
905            Ok(Transaction {
906                inner: TransactionInner::Postgres(tx),
907            })
908        }
909    }
910}
911
912/// Begin a transaction against the pool registered under `alias` (audit_2
913/// core-app-config #5).
914///
915/// [`begin`] / [`transaction`] always target the `"default"` pool — they
916/// consult neither the [`DatabaseRouter`] nor per-model aliases nor the tenant
917/// route context. In a multi-DB or DB-per-tenant app, a model routed to a
918/// replica/tenant alias run inside a plain `transaction()` would execute its
919/// SQL on the DEFAULT database — a silent wrong-database write. Use this to
920/// pin the transaction to the intended pool; `Model::objects().on_tx(&mut tx)`
921/// then runs every statement on `alias`'s pool regardless of the model's own
922/// routing.
923///
924/// # Panics
925///
926/// Panics if `App::build()` hasn't run, or if no pool is registered under
927/// `alias` (same contract as [`pool_for_dispatched`]).
928pub async fn begin_for(alias: &str) -> Result<Transaction, sqlx::Error> {
929    match pool_for_dispatched(alias) {
930        DbPool::Sqlite(pool) => Ok(Transaction {
931            // BEGIN IMMEDIATE for SQLite — see `begin()`.
932            inner: TransactionInner::Sqlite(pool.begin_with("BEGIN IMMEDIATE").await?),
933        }),
934        DbPool::Postgres(pool) => Ok(Transaction {
935            inner: TransactionInner::Postgres(pool.begin().await?),
936        }),
937    }
938}
939
940/// Begin a transaction against an explicit SQLite pool.
941pub async fn begin_sqlite(pool: &sqlx::SqlitePool) -> Result<Transaction, sqlx::Error> {
942    // BEGIN IMMEDIATE for SQLite — see `begin()`.
943    let tx = pool.begin_with("BEGIN IMMEDIATE").await?;
944    Ok(Transaction {
945        inner: TransactionInner::Sqlite(tx),
946    })
947}
948
949/// Begin a transaction against an explicit Postgres pool.
950pub async fn begin_pg(pool: &sqlx::PgPool) -> Result<Transaction, sqlx::Error> {
951    let tx = pool.begin().await?;
952    Ok(Transaction {
953        inner: TransactionInner::Postgres(tx),
954    })
955}
956
957/// Pinned, boxed `Future` with a lifetime parameter.
958///
959/// This is the required shape for the closure argument to
960/// [`transaction`] / [`transaction_sqlite`] / [`transaction_pg`].
961/// The lifetime `'a` ties the future to the `&'a mut Transaction`
962/// reference so the borrow checker can verify that the transaction
963/// outlives the async work being done inside it.
964///
965/// Call sites construct this by calling `.boxed()` or wrapping the
966/// `async move` block:
967///
968/// ```rust,ignore
969/// use futures::FutureExt;
970/// use umbral::db::{transaction, TxFuture};
971///
972/// transaction(|tx| {
973///     Box::pin(async move {
974///         Post::objects().on_tx(tx).create(new_post).await?;
975///         Ok::<_, MyError>(())
976///     })
977/// }).await?;
978/// ```
979///
980/// The `async move { ... }` block captures the `&mut Transaction` by
981/// move and the `Box::pin(...)` wrapper satisfies the HRTB bound.
982pub type TxFuture<'a, T, E> = Pin<Box<dyn std::future::Future<Output = Result<T, E>> + Send + 'a>>;
983
984/// Run an async closure inside a database transaction against the ambient pool.
985///
986/// The closure receives `&mut Transaction`. On `Ok` the transaction is
987/// committed; on `Err` it is rolled back. Returns the closure's `Ok` value
988/// on success.
989///
990/// The closure must return a `TxFuture` (a `Pin<Box<dyn Future>>`).
991/// Use `Box::pin(async move { ... })`:
992///
993/// ```rust,ignore
994/// use umbral::db::transaction;
995///
996/// let order = transaction(|tx| Box::pin(async move {
997///     let o = Order::objects().on_tx(tx).create(new_order).await?;
998///     Inventory::objects()
999///         .on_tx(tx)
1000///         .filter(inv::PRODUCT_ID.eq(sku))
1001///         .update_values(delta)
1002///         .await?;
1003///     Ok::<_, MyError>(o)
1004/// })).await?;
1005/// ```
1006///
1007/// # Panics
1008///
1009/// Panics if `App::build()` hasn't run.
1010pub async fn transaction<F, T, E>(f: F) -> Result<T, E>
1011where
1012    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1013    E: From<sqlx::Error>,
1014{
1015    let mut tx = begin().await.map_err(E::from)?;
1016    match f(&mut tx).await {
1017        Ok(val) => {
1018            tx.commit().await.map_err(E::from)?;
1019            Ok(val)
1020        }
1021        Err(e) => {
1022            // Best-effort rollback — if it fails we surface the original error.
1023            let _ = tx.rollback().await;
1024            Err(e)
1025        }
1026    }
1027}
1028
1029/// Run an async closure inside a transaction against the pool registered under
1030/// `alias` (audit_2 core-app-config #5) — the alias-aware sibling of
1031/// [`transaction`]. Use this for a multi-DB / DB-per-tenant app so the
1032/// transaction (and every `on_tx` statement inside it) runs on the RIGHT
1033/// database instead of silently on `"default"`. See [`begin_for`] for the
1034/// routing rationale and panics.
1035///
1036/// ```rust,ignore
1037/// use umbral::db::transaction_on;
1038///
1039/// transaction_on("replica_writes", |tx| Box::pin(async move {
1040///     Ledger::objects().on_tx(tx).create(entry).await?;
1041///     Ok::<_, MyError>(())
1042/// })).await?;
1043/// ```
1044pub async fn transaction_on<F, T, E>(alias: &str, f: F) -> Result<T, E>
1045where
1046    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1047    E: From<sqlx::Error>,
1048{
1049    let mut tx = begin_for(alias).await.map_err(E::from)?;
1050    match f(&mut tx).await {
1051        Ok(val) => {
1052            tx.commit().await.map_err(E::from)?;
1053            Ok(val)
1054        }
1055        Err(e) => {
1056            // Best-effort rollback — if it fails we surface the original error.
1057            let _ = tx.rollback().await;
1058            Err(e)
1059        }
1060    }
1061}
1062
1063/// Run an async closure inside a SQLite transaction against an explicit pool.
1064///
1065/// The SQLite-specific variant of [`transaction`] for callers that want to
1066/// pin to SQLite regardless of what the ambient pool is, or that are running
1067/// outside of `App::build()` (e.g. tests).
1068///
1069/// See [`transaction`] for the closure shape.
1070pub async fn transaction_sqlite<F, T, E>(pool: &sqlx::SqlitePool, f: F) -> Result<T, E>
1071where
1072    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1073    E: From<sqlx::Error>,
1074{
1075    let mut tx = begin_sqlite(pool).await.map_err(E::from)?;
1076    match f(&mut tx).await {
1077        Ok(val) => {
1078            tx.commit().await.map_err(E::from)?;
1079            Ok(val)
1080        }
1081        Err(e) => {
1082            let _ = tx.rollback().await;
1083            Err(e)
1084        }
1085    }
1086}
1087
1088/// Run an async closure inside a Postgres transaction against an explicit pool.
1089///
1090/// The Postgres-specific variant of [`transaction`] for callers that want to
1091/// pin to Postgres or run outside `App::build()`.
1092///
1093/// See [`transaction`] for the closure shape.
1094pub async fn transaction_pg<F, T, E>(pool: &sqlx::PgPool, f: F) -> Result<T, E>
1095where
1096    for<'a> F: FnOnce(&'a mut Transaction) -> TxFuture<'a, T, E>,
1097    E: From<sqlx::Error>,
1098{
1099    let mut tx = begin_pg(pool).await.map_err(E::from)?;
1100    match f(&mut tx).await {
1101        Ok(val) => {
1102            tx.commit().await.map_err(E::from)?;
1103            Ok(val)
1104        }
1105        Err(e) => {
1106            let _ = tx.rollback().await;
1107            Err(e)
1108        }
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn valid_guc_names_are_accepted_and_injection_is_rejected() {
1118        // gaps4 #16 — these names get interpolated into `RESET <name>`, so the
1119        // validator is the safety boundary. Real framework GUC names pass;
1120        // anything that could break out is refused.
1121        assert!(is_valid_guc_name("app.user_id"));
1122        assert!(is_valid_guc_name("app.tenant"));
1123        assert!(is_valid_guc_name("my_var"));
1124        // Injection / malformed shapes must fail.
1125        assert!(!is_valid_guc_name("app.user_id; DROP TABLE users"));
1126        assert!(!is_valid_guc_name("app.user_id, other"));
1127        assert!(!is_valid_guc_name("app.user id"));
1128        assert!(!is_valid_guc_name("a.b.c"));
1129        assert!(!is_valid_guc_name(""));
1130        assert!(!is_valid_guc_name("1bad"));
1131        assert!(!is_valid_guc_name("app."));
1132    }
1133
1134    // `pool` and `pool_for` read the process-wide `POOLS` `OnceLock`, which
1135    // can only be set once per process. Under cargo test's parallel runner
1136    // that makes them unreliable to cover directly without `serial_test` or
1137    // a refactor, so they're intentionally out of scope here. Same reason
1138    // the "pool() panics before init" path isn't exercised: another test in
1139    // the same process may have already populated the lock.
1140    //
1141    // Mirrors the settings module's stance on its own `init`/`get` pair.
1142
1143    /// `connect` hands back a SQLite pool wrapped in `DbPool::Sqlite` we
1144    /// can actually run queries through.
1145    #[tokio::test]
1146    async fn connect_returns_a_working_pool_against_in_memory_sqlite() {
1147        let pool = connect("sqlite::memory:")
1148            .await
1149            .expect("in-memory sqlite should always connect");
1150
1151        let sqlite = pool.as_sqlite().expect("should be Sqlite variant");
1152        let (one,): (i64,) = sqlx::query_as("SELECT 1")
1153            .fetch_one(sqlite)
1154            .await
1155            .expect("SELECT 1 should succeed on a fresh pool");
1156
1157        assert_eq!(one, 1);
1158    }
1159
1160    /// A URL sqlx can't parse surfaces as a plain `sqlx::Error`. We don't
1161    /// pin the variant — the family is the contract.
1162    #[tokio::test]
1163    async fn connect_errors_on_malformed_url() {
1164        let result = connect("not-a-real-url").await;
1165        assert!(
1166            result.is_err(),
1167            "expected sqlx to reject a malformed url, got Ok"
1168        );
1169    }
1170
1171    /// MySQL and similar schemes that umbral hasn't shipped yet
1172    /// surface as a clear configuration error rather than a
1173    /// driver-internal one.
1174    #[tokio::test]
1175    async fn connect_rejects_unsupported_scheme() {
1176        let result = connect("mysql://user:pass@host/db").await;
1177        match result {
1178            Err(sqlx::Error::Configuration(msg)) => {
1179                assert!(msg.to_string().contains("mysql"));
1180            }
1181            other => panic!("expected Configuration error, got {other:?}"),
1182        }
1183    }
1184
1185    /// `From<SqlitePool>` and the variant accessors round-trip.
1186    #[tokio::test]
1187    async fn sqlite_pool_round_trips_through_dbpool() {
1188        let sp = SqlitePool::connect("sqlite::memory:").await.unwrap();
1189        let dp: DbPool = sp.clone().into();
1190        assert_eq!(dp.backend_name(), "sqlite");
1191        assert!(dp.as_sqlite().is_some());
1192        assert!(dp.as_postgres().is_none());
1193    }
1194}