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