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}