Skip to main content

autumn_web/
db.rs

1//! Database connection pool and extractor.
2//!
3//! This module provides async Postgres connectivity via `diesel-async` with
4//! the `deadpool` connection pool. The pool is created at startup by
5//! [`AppBuilder::run`](crate::app::AppBuilder::run) and stored in
6//! [`crate::state::AppState`].
7//!
8//! When no `database.primary_url` or legacy `database.url` is configured,
9//! [`create_pool`] returns `Ok(None)` and the application runs without a
10//! database -- useful for static-site or API-gateway use cases.
11//!
12//! # The [`Db`] extractor
13//!
14//! Declare `db: Db` in your handler signature to get a pooled connection.
15//! The connection is automatically returned to the pool when `Db` is dropped
16//! at the end of the request.
17//!
18//! ```rust,no_run
19//! use autumn_web::prelude::*;
20//!
21//! #[get("/hello")]
22//! async fn hello(db: Db) -> AutumnResult<String> {
23//!     // Use `db` with Diesel queries...
24//!     Ok("hello from db".to_string())
25//! }
26//! ```
27
28use axum::extract::FromRequestParts;
29use diesel;
30// Named by the default Postgres pool builder/TLS connector and by the
31// Postgres migration connection (`MigrationConnection`), which stays compiled
32// under the `sqlite` feature (diesel's `postgres` backend is still in the graph
33// via `db`), so this import is used on both builds.
34use diesel_async::AsyncPgConnection;
35use diesel_async::pooled_connection::AsyncDieselConnectionManager;
36use diesel_async::pooled_connection::deadpool::Pool;
37use futures::FutureExt as _;
38use std::any::Any;
39use std::future::Future;
40use std::panic::AssertUnwindSafe;
41use std::pin::Pin;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::{Arc, Mutex};
44use std::time::Duration;
45use tracing::Instrument as _;
46
47use crate::config::DatabaseConfig;
48use crate::error::AutumnError;
49
50/// The database connection type the runtime is built against.
51///
52/// Defaults to Postgres (`AsyncPgConnection`). Enabling the crate's `sqlite`
53/// feature flips it to a `SQLite` connection. Threading this alias (instead of a
54/// hard-coded `AsyncPgConnection`) through the connection-typed surface — the
55/// pool, the pooled connection, the transaction/query helpers, and the
56/// `#[repository]`/`#[model]` generated code — is what lets the same codebase
57/// target either backend.
58///
59/// FEATURE-UNIFICATION HAZARD: the `sqlite` feature must ONLY be enabled by an
60/// end application (or an explicit `--features sqlite` build). If any workspace
61/// crate or dev-dependency enables it, cargo feature unification flips this
62/// alias for every consumer in the graph and breaks the Postgres default. See
63/// the doc comment above `sqlite = []` in `autumn/Cargo.toml`.
64#[cfg(not(feature = "sqlite"))]
65pub type RuntimeConnection = diesel_async::AsyncPgConnection;
66/// See [`RuntimeConnection`] (Postgres variant) for the full contract. Under
67/// the `sqlite` feature the runtime is built against a `SQLite` connection.
68#[cfg(feature = "sqlite")]
69pub type RuntimeConnection =
70    diesel_async::sync_connection_wrapper::SyncConnectionWrapper<diesel::SqliteConnection>;
71
72/// The diesel query backend the runtime is built against — the companion of
73/// [`RuntimeConnection`].
74///
75/// Defaults to Postgres (`diesel::pg::Pg`); the `sqlite` feature flips it to
76/// `diesel::sqlite::Sqlite`. Generated `#[repository]`/`#[model]` CRUD names
77/// this alias (as `::autumn_web::RuntimeBackend`) wherever a diesel
78/// `QueryFragment<_>` / `SelectableHelper<_>` bound must resolve to the *active*
79/// backend rather than a hard-coded `Pg`. Threading it (instead of `Pg`) through
80/// the always-emitted upsert set-clause and boxed-query types is what lets the
81/// same generated code type-check on either backend. Genuinely Postgres-only
82/// query fragments (advisory-lock upserts, FTS `searchable`) keep an explicit
83/// `Pg` bound — they are not portable and are cfg-gated off under `sqlite`.
84#[cfg(not(feature = "sqlite"))]
85pub type RuntimeBackend = diesel::pg::Pg;
86/// See [`RuntimeBackend`] (Postgres variant). Under the `sqlite` feature the
87/// runtime query backend is `diesel::sqlite::Sqlite`.
88#[cfg(feature = "sqlite")]
89pub type RuntimeBackend = diesel::sqlite::Sqlite;
90
91// ── After-commit callback infrastructure ─────────────────────────────────────
92
93/// A boxed async callback registered for post-transaction execution.
94///
95/// Stored in [`AFTER_COMMIT_REGISTRY`] during an active [`Db::tx`] block.
96/// The registry is drained and each callback is awaited after the transaction
97/// commits successfully. On rollback or panic the callbacks are dropped
98/// without being called.
99pub type CommitCallback = Box<
100    dyn FnOnce() -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'static>>
101        + Send
102        + 'static,
103>;
104
105tokio::task_local! {
106    /// Task-local registry used by [`Db::tx`] to accumulate after-commit
107    /// callbacks. Only set while the [`Db::tx`] future is being polled;
108    /// absent outside a transaction block.
109    pub static AFTER_COMMIT_REGISTRY: Arc<Mutex<Vec<CommitCallback>>>;
110}
111
112/// Per-request accumulator for database query timing, used by the
113/// `Server-Timing` middleware to surface `db;dur=…;desc="N queries"`.
114///
115/// Populated by the [`RequestQueryTimer`] connection instrumentation, which
116/// [`Db::checkout`] installs only on connections checked out while this
117/// task-local is scoped (i.e. while the `ServerTimingLayer` has scoped the
118/// request — see [`request_db_timing_active`]). The timer only *records* when
119/// the task-local is scoped; a stale one left on a reused connection has an
120/// `on_start` that is a cheap bool-probe no-op formatting and recording nothing. It
121/// fires on every diesel-async query (including the raw `.load()`/`.execute()`
122/// calls generated repositories run). The connection instrumentation is the
123/// sole writer during a request — helpers like [`run_instrumented`]
124/// deliberately do *not* record here, to avoid double-counting the same
125/// statement. When the middleware is disabled the scope is absent, so opted-out
126/// requests pay only that per-query bool probe (no `DebugQuery` formatting, no
127/// allocation).
128#[derive(Default, Debug)]
129pub(crate) struct RequestDbTimings {
130    /// Total elapsed time across all DB queries for this request, in
131    /// microseconds. Microseconds keep the header format (`f64` ms rounded
132    /// to three decimals) consistent with the access-log clock without an
133    /// f64 atomic.
134    pub(crate) total_us: AtomicU64,
135    /// Number of instrumented DB queries this request performed.
136    pub(crate) query_count: std::sync::atomic::AtomicUsize,
137}
138
139tokio::task_local! {
140    /// Task-local accumulator populated by DB instrumentation and read by
141    /// the `Server-Timing` middleware. See [`RequestDbTimings`].
142    pub(crate) static REQUEST_DB_TIMINGS: Arc<RequestDbTimings>;
143}
144
145tokio::task_local! {
146    /// Task-local sink for request-wide SQL query capture, independent of the
147    /// `Server-Timing` timing accumulator ([`REQUEST_DB_TIMINGS`]).
148    ///
149    /// Scoped by the test harness (`RequestBuilder::send`) around the whole
150    /// request so every instrumented statement is retained as a
151    /// [`crate::inspector::QueryRecord`] for `TestResponse` query-count / N+1
152    /// assertions. Kept as a separate task-local lane — not folded into
153    /// `RequestDbTimings` — so query capture is unaffected by how the
154    /// `Server-Timing` middleware scopes (and nests) its per-scope timing
155    /// accumulators. Absent in production, where the capture lane is never
156    /// scoped and nothing is retained.
157    #[cfg(feature = "db")]
158    pub(crate) static REQUEST_QUERY_CAPTURE: Arc<Mutex<Vec<crate::inspector::QueryRecord>>>;
159}
160
161/// Strip the trailing `-- binds: [...]` annotation that diesel's `DebugQuery`
162/// `Display` appends to a query's SQL text.
163///
164/// diesel-async feeds each real query into the `StartQuery` instrumentation
165/// event as a `DebugQuery` (see `diesel::debug_query` /
166/// `AsyncPgConnection::with_prepared_statement`), whose `Display` renders
167/// `"{sql} -- binds: {binds:?}"` — the format lives in
168/// `diesel::query_builder::debug_query::display`
169/// (`write!(f, "{query} -- binds: {debug_binds:?}")`). So the SQL text we
170/// materialise from `query.to_string()` is e.g.
171/// `SELECT * FROM books WHERE author_id = $1 -- binds: [1]`.
172///
173/// The per-row executions of an N+1 pattern share the same parameterised
174/// statement (`… WHERE author_id = $1`) and differ only in their bind values
175/// (`-- binds: [1]`, `-- binds: [2]`, …). If the capture path retained that
176/// annotation, [`crate::inspector::normalize_sql`] (which only collapses
177/// whitespace and lower-cases) would treat each per-row execution as a distinct
178/// template, so [`crate::inspector::detect_n_plus_one`] would never see the
179/// repetition and `assert_no_n_plus_one()` would miss the N+1 it exists to
180/// catch. Truncating at the `-- binds` marker leaves the parameterised
181/// statement — `$N` placeholders intact — so repeated per-row queries collapse
182/// to one template.
183///
184/// Robust to input without the marker (transaction-control SQL diesel runs via
185/// `batch_execute`, or synthetic test input): returns the input unchanged. Uses
186/// the last occurrence, since diesel always appends the annotation after the
187/// full statement.
188#[cfg(feature = "db")]
189fn strip_bind_annotation(sql: &str) -> &str {
190    sql.rfind("-- binds:")
191        .map_or(sql, |idx| sql[..idx].trim_end())
192}
193
194/// Record one instrumented DB query into the current request's
195/// [`REQUEST_DB_TIMINGS`], if any. No-op when the task-local is unset —
196/// which is the case whenever the `Server-Timing` middleware is disabled
197/// or the query runs outside a request (e.g. background job).
198pub(crate) fn record_request_db_query(elapsed: Duration, sql: Option<&str>) {
199    // Lane 1: the `Server-Timing` timing accumulator (count + cumulative time).
200    let _ = REQUEST_DB_TIMINGS.try_with(|t| {
201        let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
202        t.total_us.fetch_add(micros, Ordering::Relaxed);
203        t.query_count
204            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
205    });
206
207    // Lane 2: request-wide SQL capture, independent of the timing lane. Only
208    // active when a test scoped `REQUEST_QUERY_CAPTURE`; a no-op otherwise
209    // (production, background jobs). The two `try_with` calls are independent —
210    // either lane may be active without the other — and no lock is held across
211    // an await.
212    #[cfg(feature = "db")]
213    if let Some(sql) = sql {
214        let _ = REQUEST_QUERY_CAPTURE.try_with(|sink| {
215            if let Ok(mut list) = sink.lock() {
216                list.push(crate::inspector::QueryRecord {
217                    // Store the parameterised statement WITHOUT diesel's per-row
218                    // `-- binds: [...]` annotation (the `$N` placeholders stay), so
219                    // repeated per-row queries normalise to the same template and
220                    // `detect_n_plus_one` can see the repetition. See
221                    // [`strip_bind_annotation`].
222                    sql: strip_bind_annotation(sql).to_owned(),
223                    params: Vec::new(),
224                    elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
225                    location: String::new(),
226                });
227            }
228        });
229    }
230}
231
232/// Whether the current task is inside a [`REQUEST_DB_TIMINGS`] scope — i.e.
233/// whether the `ServerTimingLayer` (only active when `[observability]
234/// server_timing` is enabled) has wrapped this request's handler future.
235///
236/// Cheap: probes the task-local without cloning the `Arc`, allocating, or
237/// formatting anything. Probed by [`RequestQueryTimer::on_start`] on every
238/// query: when no scope is active (the production default, `server_timing`
239/// unset), the timer records nothing and skips the `DebugQuery` formatting, so
240/// an always-installed timer costs opted-out requests only this bool probe per
241/// query — no per-statement allocation.
242#[cfg(feature = "db")]
243pub(crate) fn request_db_timing_active() -> bool {
244    REQUEST_DB_TIMINGS.try_with(|_| ()).is_ok()
245}
246
247/// Whether the current task is inside a [`REQUEST_QUERY_CAPTURE`] scope — i.e.
248/// whether the test harness (`RequestBuilder::send`) has scoped a capture sink
249/// around this request.
250///
251/// Mirrors [`request_db_timing_active`]: a cheap task-local probe that neither
252/// clones the `Arc` nor allocates. [`Db::checkout`] installs the
253/// [`RequestQueryTimer`] instrumentation when EITHER this lane or the timing
254/// lane is active, so query capture works even when `server_timing` is off (the
255/// common test case) and no `REQUEST_DB_TIMINGS` scope exists.
256#[cfg(feature = "db")]
257pub(crate) fn request_query_capture_active() -> bool {
258    REQUEST_QUERY_CAPTURE.try_with(|_| ()).is_ok()
259}
260
261/// diesel-async [`Instrumentation`](diesel::connection::Instrumentation)
262/// that feeds every executed statement into the per-request
263/// [`REQUEST_DB_TIMINGS`] accumulator for the `Server-Timing` `db` metric.
264///
265/// Installed on a pooled connection at [`Db::checkout`] only when a
266/// [`REQUEST_DB_TIMINGS`] scope is active (see [`request_db_timing_active`]),
267/// i.e. only for requests the `ServerTimingLayer` is measuring — so opted-out
268/// requests never install it and pay no per-query overhead. Because generated
269/// repositories run raw `diesel-async` `.load()`/`.execute()` (they do not go
270/// through [`run_instrumented`]), the connection-level instrumentation is the
271/// only thing that observes those queries. It brackets each statement between
272/// the `StartQuery`/`FinishQuery` events the connection emits and records the
273/// elapsed wall time via [`record_request_db_query`], which is a no-op when no
274/// request has scoped the task-local (background jobs, or the middleware being
275/// disabled) — so it never panics off-request.
276///
277/// Only genuine application statements are timed. The dedicated
278/// connection-establish, prepared-statement-cache, and
279/// `BeginTransaction`/`CommitTransaction`/`RollbackTransaction` events are
280/// ignored outright. Crucially, diesel-async runs the transaction-control SQL
281/// itself (`BEGIN`, `COMMIT`, `ROLLBACK`, and the `SAVEPOINT`/`RELEASE`
282/// variants for nested transactions) through `batch_execute`, which emits a
283/// `StartQuery`/`FinishQuery` pair with that SQL — exactly like a real query.
284/// Left unfiltered, a transaction wrapping a single `SELECT` would report
285/// `desc="3 queries"` (BEGIN + SELECT + COMMIT) and fold begin/commit latency
286/// into `db;dur`. Likewise [`Db::checkout`] issues a `SET statement_timeout`
287/// housekeeping statement on every checkout, which would otherwise add a bogus
288/// `+1 query` to every request before any app SQL runs. So at `StartQuery` we
289/// inspect the statement text and skip timing when it is a housekeeping /
290/// transaction-control command (see
291/// [`RequestQueryTimer::is_uncounted_statement`]). Queries on a single
292/// connection are strictly sequential (`&mut conn`), so a single
293/// `Option<Instant>` slot is sufficient — there is no overlap between a start
294/// and its finish, and skipping a start leaves the slot empty so the matching
295/// finish is a no-op.
296///
297/// The timer is installed at [`Db::checkout`] (before the housekeeping `SET`)
298/// **only when a `REQUEST_DB_TIMINGS` scope is active** — i.e. only for requests
299/// the `ServerTimingLayer` is measuring. This gate matters because
300/// `set_instrumentation` wholesale replaces the connection's instrumentation:
301/// installing unconditionally would clobber any global default an application
302/// registered via `diesel::connection::set_default_instrumentation` (query
303/// logging, tracing, metrics), even when `server_timing` is disabled. Gating on
304/// the scope leaves an opted-out app's instrumentation fully intact. When the
305/// scope *is* active, installing a fresh timer per checkout also clears any
306/// stale timer a pooled connection carried from a prior timed request. A stale
307/// timer left on a connection later reused by an opted-out request is a cheap
308/// no-op: `on_start` probes [`request_db_timing_active`] before doing any work,
309/// so it never formats SQL or allocates off-scope.
310///
311/// Autumn does not currently *compose* with an app-registered
312/// `set_default_instrumentation` — while `server_timing` is enabled its timer
313/// replaces the app's on measured checkouts. This is a documented limitation
314/// (`server_timing` is a dev/off-by-default feature); apps needing both should
315/// keep it disabled in that environment.
316#[cfg(feature = "db")]
317#[derive(Default, Debug)]
318pub(crate) struct RequestQueryTimer {
319    /// The currently in-flight statement (start instant + SQL text), if any.
320    pending: Option<PendingQuery>,
321}
322
323/// A statement whose `StartQuery` has fired but whose `FinishQuery` has not.
324///
325/// Holds the start instant (for latency) and the materialised SQL text (so a
326/// capturing accumulator can retain it). Queries on a single connection are
327/// strictly sequential, so a single slot suffices.
328#[cfg(feature = "db")]
329#[derive(Debug)]
330struct PendingQuery {
331    started_at: std::time::Instant,
332    sql: String,
333}
334
335#[cfg(feature = "db")]
336impl RequestQueryTimer {
337    /// Whether `sql` is a statement that must **not** be counted as an
338    /// application query in the `Server-Timing` `db` metric. Two kinds reach
339    /// the connection instrumentation but are not application work:
340    ///
341    /// * **Transaction control** that diesel-async runs via `batch_execute`
342    ///   (which emits a `StartQuery`/`FinishQuery` pair just like a real
343    ///   query): `BEGIN`, `COMMIT`, `ROLLBACK`, the `SAVEPOINT`/`RELEASE`
344    ///   nested-transaction variants, and the two-word `START TRANSACTION` form.
345    /// * **Session/config housekeeping** — any leading-token `SET`. This covers
346    ///   the `SET statement_timeout` that [`Db::checkout`] issues on every
347    ///   checkout before handing the connection to the request, as well as
348    ///   `SET TRANSACTION`. None of these are application queries.
349    ///
350    /// Matches a case-insensitive leading token in
351    /// `{BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, SET}`, plus the two-word
352    /// `START TRANSACTION` form. See the type-level docs.
353    fn is_uncounted_statement(sql: &str) -> bool {
354        let mut tokens = sql.split_whitespace();
355        let Some(first) = tokens.next() else {
356            return false;
357        };
358        match first.to_ascii_uppercase().as_str() {
359            "BEGIN" | "COMMIT" | "ROLLBACK" | "SAVEPOINT" | "RELEASE" | "SET" => true,
360            "START" => tokens
361                .next()
362                .is_some_and(|second| second.eq_ignore_ascii_case("TRANSACTION")),
363            _ => false,
364        }
365    }
366
367    /// Record the start of a statement.
368    ///
369    /// Probes both [`request_db_timing_active`] and
370    /// [`request_query_capture_active`] first: when NEITHER the timing
371    /// accumulator nor the capture sink is scoped (the opted-out / off-request
372    /// path) the timer does nothing and — crucially — never invokes `sql`, so
373    /// the caller's `DebugQuery` `to_string()` allocation is skipped entirely.
374    /// This is what makes it safe to leave a `RequestQueryTimer` installed on a
375    /// pooled connection that a later opted-out request reuses: the stale timer
376    /// is a cheap bool-probe no-op, not a per-query allocator.
377    ///
378    /// When either lane *is* active, the SQL text is materialised and inspected:
379    /// housekeeping / transaction-control statements (see
380    /// [`Self::is_uncounted_statement`], e.g. the checkout `SET
381    /// statement_timeout`) leave the slot empty so they are excluded from the
382    /// accumulator; genuine application statements record their start instant.
383    /// Clearing the slot keeps the matching `FinishQuery` a no-op.
384    ///
385    /// `sql` is a closure so the (allocating) formatting is deferred until we
386    /// know the statement will be counted. Extracted from the event handler so
387    /// the start/finish accounting is unit-testable without constructing a
388    /// (non-exhaustive, unstable-to-build) `InstrumentationEvent`.
389    fn on_start(&mut self, now: std::time::Instant, sql: impl FnOnce() -> String) {
390        // Probe BOTH lanes: the timing accumulator (`server_timing`) and the
391        // query-capture sink (test harness). Either being active means the
392        // upcoming statement must be observed. When neither is scoped (the
393        // opted-out / off-request path) the timer does nothing and never
394        // invokes `sql`, so the caller's `DebugQuery` `to_string()` allocation
395        // is skipped — keeping a stale installed timer a cheap bool-probe no-op.
396        if !request_db_timing_active() && !request_query_capture_active() {
397            self.pending = None;
398            return;
399        }
400        // Materialise the SQL once: it is needed both for the housekeeping
401        // filter and (when a test opted into capture) to retain the statement
402        // text at `on_finish`.
403        let sql = sql();
404        self.pending = if Self::is_uncounted_statement(&sql) {
405            None
406        } else {
407            Some(PendingQuery {
408                started_at: now,
409                sql,
410            })
411        };
412    }
413
414    /// Record the completion of the in-flight statement, accumulating its
415    /// elapsed time into the per-request accumulator. A `FinishQuery` without
416    /// a matching `StartQuery` is ignored.
417    fn on_finish(&mut self, now: std::time::Instant) {
418        if let Some(p) = self.pending.take() {
419            record_request_db_query(now.saturating_duration_since(p.started_at), Some(&p.sql));
420        }
421    }
422}
423
424#[cfg(feature = "db")]
425impl diesel::connection::Instrumentation for RequestQueryTimer {
426    fn on_connection_event(&mut self, event: diesel::connection::InstrumentationEvent<'_>) {
427        use diesel::connection::InstrumentationEvent;
428        match event {
429            InstrumentationEvent::StartQuery { query, .. } => {
430                // `query` is an opaque `&dyn DebugQuery`; its `Display` impl
431                // renders the SQL text, which we inspect to skip housekeeping /
432                // transaction-control statements (see the type-level docs). The
433                // `to_string()` is deferred behind a closure so an installed but
434                // opted-out timer never pays the allocation — see `on_start`.
435                self.on_start(std::time::Instant::now(), || query.to_string());
436            }
437            InstrumentationEvent::FinishQuery { .. } => self.on_finish(std::time::Instant::now()),
438            // Ignore connection-establish, prepared-statement cache, and the
439            // dedicated Begin/Commit/RollbackTransaction events — see the
440            // type-level docs.
441            _ => {}
442        }
443    }
444}
445
446/// Total count of after-commit callback errors since process start.
447///
448/// Incremented each time a callback registered via [`register_after_commit`]
449/// or [`Db::tx`] returns an error **after** the transaction has already
450/// committed. The underlying transaction is unaffected; this counter surfaces
451/// failures for alerting and dashboards.
452///
453/// Exposed by the `/actuator/health` endpoint as the top-level
454/// `autumn_after_commit_failures_total` field.
455pub static AFTER_COMMIT_FAILURES_TOTAL: AtomicU64 = AtomicU64::new(0);
456
457pub(crate) fn record_after_commit_failure() -> u64 {
458    AFTER_COMMIT_FAILURES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
459}
460
461/// Total number of transaction retries triggered by a transient serialization
462/// failure (`40001`) or deadlock (`40P01`) since process start.
463///
464/// Incremented by [`Db::tx_with`] each time a retryable transaction error is
465/// re-run under a stronger isolation level. Surfaces contention on the existing
466/// metrics surface (`autumn_tx_retries_total`).
467pub static TX_RETRIES_TOTAL: AtomicU64 = AtomicU64::new(0);
468
469/// Total number of transactions that exhausted their retry budget without
470/// succeeding, since process start. Exposed as `autumn_tx_retry_exhausted_total`.
471pub static TX_RETRY_EXHAUSTED_TOTAL: AtomicU64 = AtomicU64::new(0);
472
473// The transaction serialization-failure retry loop is Postgres-only (SQLite's
474// `tx_with` runs a single plain transaction), so these retry-metric helpers are
475// unused in a `--features sqlite` library build. They are still exercised by the
476// crate's unit tests, so keep them compiled and only silence the dead-code
477// warning under the feature.
478#[cfg_attr(feature = "sqlite", allow(dead_code))]
479pub(crate) fn record_tx_retry() -> u64 {
480    TX_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
481}
482
483#[cfg_attr(feature = "sqlite", allow(dead_code))]
484pub(crate) fn record_tx_retry_exhausted() -> u64 {
485    TX_RETRY_EXHAUSTED_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
486}
487
488/// Guidance appended to the nested-`tx` rejection, naming the supported
489/// alternative for a same-connection nested transaction.
490const NESTED_TX_MESSAGE: &str = "Nested Db::tx calls are not supported; use \
491    autumn_web::db::savepoint(conn, ..) inside the closure for a same-connection savepoint";
492
493pub(crate) fn reject_ambient_after_commit_registry_for_tx() -> Result<(), AutumnError> {
494    if AFTER_COMMIT_REGISTRY.try_with(|_| ()).is_ok() {
495        return Err(AutumnError::bad_request_msg(NESTED_TX_MESSAGE));
496    }
497    Ok(())
498}
499
500pub(crate) fn spawn_committed_after_commit_callbacks(
501    callbacks: Vec<CommitCallback>,
502) -> Option<tokio::task::JoinHandle<()>> {
503    if callbacks.is_empty() {
504        return None;
505    }
506
507    Some(tokio::task::spawn(async move {
508        for cb in callbacks {
509            let result = match std::panic::catch_unwind(AssertUnwindSafe(cb)) {
510                Ok(callback) => AssertUnwindSafe(callback).catch_unwind().await,
511                Err(panic) => Err(panic),
512            };
513
514            match result {
515                Ok(Ok(())) => {}
516                Ok(Err(e)) => {
517                    let failures_total = record_after_commit_failure();
518                    tracing::error!(
519                        autumn.after_commit.failures_total = failures_total,
520                        "after_commit callback failed (tx already committed): {e}"
521                    );
522                }
523                Err(panic) => {
524                    let failures_total = record_after_commit_failure();
525                    let panic = after_commit_panic_message(&*panic);
526                    tracing::error!(
527                        autumn.after_commit.failures_total = failures_total,
528                        "after_commit callback panicked (tx already committed): {panic}"
529                    );
530                }
531            }
532        }
533    }))
534}
535
536fn after_commit_panic_message(payload: &(dyn Any + Send)) -> String {
537    match (
538        payload.downcast_ref::<&'static str>(),
539        payload.downcast_ref::<String>(),
540    ) {
541        (Some(message), _) => (*message).to_owned(),
542        (_, Some(message)) => message.clone(),
543        (None, None) => "non-string panic payload".to_owned(),
544    }
545}
546
547/// Register a callback to run after the current database transaction commits.
548///
549/// If called inside a [`Db::tx`] block, the callback is deferred until the
550/// transaction commits successfully. On rollback the callback is dropped
551/// without being called.
552///
553/// The deferred callback is process-local work spawned after commit. It avoids
554/// side effects for rolled-back transactions, but it is not a crash-safe
555/// delivery mechanism. For side effects that must survive process exit, write a
556/// durable outbox or queue row inside the same database transaction and use
557/// this callback only as an optional wake-up hint.
558///
559/// If called **outside** any active transaction, the callback runs immediately
560/// (eager execution) with a `debug`-level log note.
561///
562/// # Panics
563///
564/// Panics if the internal registry mutex is poisoned (only possible if a
565/// previous thread holding the lock panicked, which should not occur in normal
566/// operation).
567///
568/// # Example
569///
570/// ```rust,ignore
571/// db.tx(move |conn| {
572///     scoped_boxed(async move {
573///         diesel::insert_into(users::table).values(&new_user).execute(conn).await?;
574///         autumn_web::db::register_after_commit(|| async {
575///             welcome_email_job.enqueue("user_id", user_id).await
576///         }).await;
577///         Ok(())
578///     })
579/// }).await?;
580/// ```
581pub async fn register_after_commit<F, Fut>(f: F)
582where
583    F: FnOnce() -> Fut + Send + 'static,
584    Fut: Future<Output = crate::AutumnResult<()>> + Send + 'static,
585{
586    let mut f_opt = Some(f);
587    AFTER_COMMIT_REGISTRY
588        .try_with(|registry| {
589            let f = f_opt.take().expect("closure only entered once");
590            let boxed: CommitCallback = Box::new(move || Box::pin(f()));
591            registry.lock().expect("registry lock").push(boxed);
592        })
593        .ok();
594
595    // If still Some, the task-local wasn't set — we're outside a tx; run eagerly.
596    if let Some(f) = f_opt {
597        tracing::debug!("register_after_commit: no active transaction; running callback eagerly");
598        if let Err(e) = f().await {
599            let failures_total = record_after_commit_failure();
600            tracing::error!(
601                autumn.after_commit.failures_total = failures_total,
602                "register_after_commit eager callback failed: {e}"
603            );
604        }
605    }
606}
607
608/// Trait to abstract the state requirement for the `Db` extractor.
609/// This breaks the circular dependency between the database extractor
610/// and the central `AppState`.
611pub trait DbState {
612    /// Returns the database connection pool, if configured.
613    fn pool(&self) -> Option<&Pool<RuntimeConnection>>;
614
615    /// Returns the metrics collector, if configured.
616    fn metrics(&self) -> Option<&crate::middleware::MetricsCollector> {
617        None
618    }
619
620    /// Returns the read/replica connection pool, if configured.
621    fn replica_pool(&self) -> Option<&Pool<RuntimeConnection>> {
622        None
623    }
624
625    /// Returns the pool used for read-only work.
626    ///
627    /// Defaults to the replica role when present, otherwise the primary role.
628    fn read_pool(&self) -> Option<&Pool<RuntimeConnection>> {
629        self.replica_pool().or_else(|| self.pool())
630    }
631
632    /// Returns the configured shard set, when `[[database.shards]]`
633    /// entries exist. Defaults to `None` so unsharded states need no
634    /// changes.
635    fn shards(&self) -> Option<&crate::sharding::ShardSet> {
636        None
637    }
638
639    /// Returns any registered database connection checkout interceptors.
640    fn db_interceptors(
641        &self,
642    ) -> Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>> {
643        Vec::new()
644    }
645    /// Returns the global statement timeout, if configured.
646    fn statement_timeout(&self) -> Option<std::time::Duration> {
647        None
648    }
649
650    /// Returns the slow query threshold.
651    fn slow_query_threshold(&self) -> std::time::Duration {
652        std::time::Duration::from_millis(500)
653    }
654}
655
656// ── SQL telemetry helpers ─────────────────────────────────────────────────────
657
658/// Scrub a SQL string to remove literal parameter values.
659///
660/// Replaces values with `?` placeholders to prevent PII leakage in
661/// slow-query logs while still surfacing the query shape for performance
662/// analysis.
663///
664/// Rules:
665/// - Single-quoted string literals `'...'` → `'?'`
666/// - Unquoted integer/float literals → `?`
667/// - Postgres `$N` positional parameters are left untouched
668///
669/// # Examples
670///
671/// ```
672/// use autumn_web::db::scrub_sql;
673///
674/// assert_eq!(scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
675///            "SELECT * FROM users WHERE name = '?'");
676/// assert_eq!(scrub_sql("SELECT * FROM orders WHERE id = 42"),
677///            "SELECT * FROM orders WHERE id = ?");
678/// assert_eq!(scrub_sql("SELECT * FROM t WHERE x = $1"),
679///            "SELECT * FROM t WHERE x = $1");
680/// ```
681/// Consumes the body of an E-string escape literal and its closing `'`.
682///
683/// Called after the opening `'` has already been consumed. Handles
684/// `\'` backslash-escaped quotes so they do not prematurely close the string.
685fn consume_estring_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
686    loop {
687        match chars.next() {
688            None => break,
689            Some('\'') => {
690                if chars.peek() == Some(&'\'') {
691                    chars.next(); // consume the doubled quote
692                } else {
693                    break;
694                }
695            }
696            Some('\\') => {
697                chars.next(); // skip the character after the backslash
698            }
699            Some(_) => {}
700        }
701    }
702}
703
704/// Consumes the body of a dollar-quoted string and its closing `$tag$`.
705///
706/// Called after the opening `$tag$` delimiter has already been consumed.
707/// Uses a simple sliding-window match — sufficient for valid SQL.
708fn consume_dollar_quoted_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, tag: &str) {
709    let closing: Vec<char> = format!("${tag}$").chars().collect();
710    let clen = closing.len();
711    let mut match_count = 0usize;
712    for sc in chars.by_ref() {
713        if sc == closing[match_count] {
714            match_count += 1;
715            if match_count == clen {
716                break; // Found the closing delimiter.
717            }
718        } else {
719            match_count = 0;
720            // The current char may start a new partial match.
721            if sc == closing[0] {
722                match_count = 1;
723            }
724        }
725    }
726}
727
728/// Returns true for every char that can legally precede a bare numeric
729/// literal in SQL — whitespace, comparison, arithmetic, and structural chars.
730#[inline]
731const fn is_separator(c: char) -> bool {
732    matches!(
733        c,
734        ' ' | '\t' | '\n'          // whitespace
735        | '=' | '<' | '>'          // comparison
736        | '!' | '+' | '-'          // arithmetic / negation (signed literals)
737        | '*' | '/' | '%'          // arithmetic operators
738        | '(' | ',' // structure
739    )
740}
741
742#[must_use]
743pub fn scrub_sql(sql: &str) -> String {
744    let mut out = String::with_capacity(sql.len());
745    // Tracks whether the last character written was a separator, so a digit
746    // at the current position starts a standalone literal rather than being
747    // part of an identifier like `table1` or `col2`.
748    let mut prev_is_sep = true; // treat start-of-input as a separator boundary
749
750    let mut chars = sql.chars().peekable();
751
752    while let Some(c) = chars.next() {
753        // ── E-string literal  E'...' / e'...'  (backslash-escape aware) ──
754        // Must be checked before the single-quote handler so we consume the
755        // `E` prefix and don't leave it in the fingerprint.
756        if (c == 'E' || c == 'e') && chars.peek() == Some(&'\'') {
757            chars.next(); // consume the opening '
758            out.push_str("'?'");
759            prev_is_sep = false;
760            consume_estring_body(&mut chars);
761            continue;
762        }
763
764        // ── Single-quoted string literal ─────────────────────────────────
765        if c == '\'' {
766            out.push_str("'?'");
767            prev_is_sep = false;
768            loop {
769                match chars.next() {
770                    None => break,
771                    Some('\'') => {
772                        if chars.peek() == Some(&'\'') {
773                            // Escaped quote ('') — consume both, stay inside string
774                            chars.next();
775                        } else {
776                            // Closing quote
777                            break;
778                        }
779                    }
780                    Some(_) => {}
781                }
782            }
783            continue;
784        }
785
786        // ── Dollar sign: positional parameter or dollar-quoted string ─────
787        if c == '$' {
788            let next_ch = chars.peek().copied();
789
790            // Positional parameter $N — pass through verbatim.
791            if next_ch.is_some_and(|nc| nc.is_ascii_digit()) {
792                out.push('$');
793                prev_is_sep = false;
794                while chars.peek().is_some_and(char::is_ascii_digit) {
795                    if let Some(d) = chars.next() {
796                        out.push(d);
797                    }
798                }
799                continue;
800            }
801
802            // Dollar-quoted string: $$ (anonymous) or $tag$ (tagged).
803            // Collect the optional tag, looking for the second `$`.
804            let mut tag = String::new();
805            let mut found_closing_dollar = false;
806
807            if next_ch == Some('$') {
808                // Anonymous $$: consume the second `$`.
809                chars.next();
810                found_closing_dollar = true;
811            } else if next_ch.is_some_and(|nc| nc.is_alphabetic() || nc == '_') {
812                // Accumulate tag chars until we hit `$` or a non-identifier char.
813                while let Some(&tc) = chars.peek() {
814                    if tc == '$' {
815                        chars.next(); // consume the closing `$` of the opening tag
816                        found_closing_dollar = true;
817                        break;
818                    } else if tc.is_alphanumeric() || tc == '_' {
819                        tag.push(tc);
820                        chars.next();
821                    } else {
822                        // Not a valid tag character — not a dollar-quoted string.
823                        break;
824                    }
825                }
826            }
827
828            if found_closing_dollar {
829                out.push_str("'?'");
830                prev_is_sep = false;
831                consume_dollar_quoted_body(&mut chars, &tag);
832            } else {
833                // Not a recognisable dollar form — emit $ and any partial tag.
834                out.push('$');
835                out.push_str(&tag);
836                prev_is_sep = false;
837            }
838            continue;
839        }
840
841        // ── Unquoted numeric literal ──────────────────────────────────────
842        // Only scrub when preceded by a separator to avoid stomping on
843        // identifiers like `table1`, `col2`, or `alias99`.
844        let is_leading_dot =
845            c == '.' && prev_is_sep && chars.peek().is_some_and(char::is_ascii_digit);
846        if (c.is_ascii_digit() && prev_is_sep) || is_leading_dot {
847            out.push('?');
848            if is_leading_dot {
849                chars.next(); // consume the leading dot
850            }
851            // Consume integer/decimal digits, underscores, and dots.
852            while chars
853                .peek()
854                .is_some_and(|d| d.is_ascii_digit() || *d == '.' || *d == '_')
855            {
856                chars.next();
857            }
858            // Consume optional scientific-notation exponent: e/E [+/-] <digits>.
859            if chars.peek().is_some_and(|e| *e == 'e' || *e == 'E') {
860                chars.next(); // consume 'e'/'E'
861                if chars.peek().is_some_and(|s| *s == '+' || *s == '-') {
862                    chars.next(); // consume optional sign
863                }
864                while chars.peek().is_some_and(char::is_ascii_digit) {
865                    chars.next();
866                }
867            }
868            prev_is_sep = false;
869            continue;
870        }
871
872        // ── Regular character ─────────────────────────────────────────────
873        out.push(c);
874        prev_is_sep = is_separator(c);
875    }
876
877    out
878}
879
880/// Instrument a database query: time it, log slow queries with a scrubbed SQL
881/// fingerprint, record metrics, and map Postgres `57014` (statement timeout)
882/// to [`AutumnError::query_timeout`].
883///
884/// # Parameters
885/// - `sql`: The raw SQL string for slow-query fingerprinting (scrubbed before logging).
886/// - `route_key`: Label string used for metrics, e.g. `"GET /users"`.
887/// - `slow_threshold`: Queries taking longer than this emit a `WARN` log.
888/// - `metrics`: The [`crate::middleware::MetricsCollector`] to record into.
889/// - `query`: The async closure that actually executes the query.
890///
891/// # Returns
892/// The result of `query()`, with Postgres `57014` mapped to
893/// [`AutumnError::query_timeout`].
894///
895/// # Errors
896/// Returns [`AutumnError`] from the underlying query, or [`AutumnError::query_timeout`]
897/// when Postgres cancels the statement due to `statement_timeout`.
898pub async fn run_instrumented<F, Fut, T>(
899    sql: &str,
900    route_key: &str,
901    slow_threshold: std::time::Duration,
902    metrics: &crate::middleware::metrics::MetricsCollector,
903    query: F,
904) -> Result<T, AutumnError>
905where
906    F: FnOnce() -> Fut,
907    Fut: std::future::Future<Output = Result<T, diesel::result::Error>>,
908{
909    let start = std::time::Instant::now();
910    let result = query().await;
911    let elapsed = start.elapsed();
912    let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
913
914    // Record metrics regardless of success/failure
915    let verb = sql.split_whitespace().next().unwrap_or("?");
916    let metric_key = format!("{route_key} {verb}");
917    metrics.record_db_query(&metric_key, elapsed_ms);
918    // NOTE: deliberately *not* recorded into the per-request Server-Timing
919    // accumulator. The connection-level `RequestQueryTimer` instrumentation
920    // (installed at `Db::checkout`) already brackets every executed statement
921    // — including any query run through this helper — so recording here too
922    // would double-count it in `db;dur` and `desc="N queries"`.
923
924    // Log slow queries with scrubbed SQL
925    if elapsed >= slow_threshold {
926        let fingerprint = scrub_sql(sql);
927        tracing::warn!(
928            route = %route_key,
929            sql = %fingerprint,
930            duration_ms = elapsed_ms,
931            "slow database query"
932        );
933    }
934
935    // Map result — translate Postgres 57014 to query_timeout
936    result.map_err(|db_err| {
937        if is_query_canceled(&db_err) {
938            tracing::warn!(
939                route = %route_key,
940                duration_ms = elapsed_ms,
941                "database query cancelled: statement_timeout exceeded"
942            );
943            AutumnError::query_timeout(format!(
944                "Database query timed out after {elapsed_ms}ms (statement_timeout exceeded)"
945            ))
946        } else {
947            AutumnError::from(db_err)
948        }
949    })
950}
951
952/// Walk `err`'s source chain looking for a `tokio_postgres` SQLSTATE matching
953/// `predicate`, downcasting each link to [`tokio_postgres::Error`] and
954/// [`tokio_postgres::error::DbError`] in turn. Shared by [`is_query_canceled`]
955/// and [`is_retryable_txn_error`] — both need the same downcast-through-the-
956/// chain strategy, only with a different SQLSTATE to look for.
957fn source_chain_has_sqlstate(
958    err: &(dyn std::error::Error + 'static),
959    predicate: impl Fn(&tokio_postgres::error::SqlState) -> bool,
960) -> bool {
961    let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
962    while let Some(e) = source {
963        if e.downcast_ref::<tokio_postgres::Error>()
964            .and_then(tokio_postgres::Error::code)
965            .is_some_and(&predicate)
966        {
967            return true;
968        }
969        if e.downcast_ref::<tokio_postgres::error::DbError>()
970            .is_some_and(|db_err| predicate(db_err.code()))
971        {
972            return true;
973        }
974        source = e.source();
975    }
976    false
977}
978
979/// Check whether a Diesel error wraps a Postgres `57014` `query_canceled` error.
980///
981/// Prefers downcasting through the source chain to find a
982/// [`tokio_postgres::Error`] and checking its SQL state code directly,
983/// which is more robust than string-matching error messages.
984fn is_query_canceled(err: &diesel::result::Error) -> bool {
985    // Robust string-matching first to catch wrapped/unwrapped representations
986    let err_str = err.to_string().to_lowercase();
987    if err_str.contains("57014")
988        || err_str.contains("query_canceled")
989        || err_str.contains("canceling statement due to statement timeout")
990        || err_str.contains("statement timeout")
991        || err_str.contains("query canceled")
992    {
993        return true;
994    }
995
996    source_chain_has_sqlstate(err, |state| {
997        *state == tokio_postgres::error::SqlState::QUERY_CANCELED
998    })
999}
1000
1001/// Error type for pool creation failures.
1002///
1003/// Returned by [`create_pool`] (and the other topology builders) when a pool
1004/// cannot be constructed. Historically this was a bare alias for deadpool's
1005/// `BuildError`; it now also carries the boot-time refusal emitted for a
1006/// recognized-but-not-yet-wired backend (`SQLite`, issue #1614), so callers fail
1007/// fast at pool construction with an actionable message instead of at the first
1008/// query. The [`Build`](PoolError::Build) variant delegates its `Display` to the
1009/// underlying `BuildError`, so the Postgres path's error text is unchanged.
1010#[derive(Debug, thiserror::Error)]
1011#[non_exhaustive]
1012pub enum PoolError {
1013    /// The underlying deadpool builder failed (e.g., timeouts configured
1014    /// without a runtime, or an invalid max-size configuration).
1015    #[error(transparent)]
1016    Build(#[from] diesel_async::pooled_connection::deadpool::BuildError),
1017
1018    /// A database backend that Autumn recognizes but whose runtime pool is not
1019    /// available in this build was configured. See
1020    /// [`DatabaseBackend`](crate::config::DatabaseBackend).
1021    #[error("{0}")]
1022    UnsupportedBackend(String),
1023}
1024
1025/// Primary plus optional read-replica database pools.
1026#[derive(Clone)]
1027pub struct DatabaseTopology {
1028    primary: Pool<RuntimeConnection>,
1029    replica: Option<Pool<RuntimeConnection>>,
1030    /// Connection URL to target with startup migrations, when the provider
1031    /// resolved one at runtime that the static config doesn't carry (e.g. the
1032    /// managed-Postgres provider whose socket URL is only known after boot).
1033    /// Scoping it to the topology keeps it per-app instead of a process global.
1034    migration_url: Option<String>,
1035}
1036
1037impl DatabaseTopology {
1038    /// Build a topology from explicit primary and optional replica pools.
1039    ///
1040    /// This is useful for custom [`DatabasePoolProvider`] implementations that
1041    /// need to create or decorate both roles themselves.
1042    #[must_use]
1043    pub const fn from_pools(
1044        primary: Pool<RuntimeConnection>,
1045        replica: Option<Pool<RuntimeConnection>>,
1046    ) -> Self {
1047        Self {
1048            primary,
1049            replica,
1050            migration_url: None,
1051        }
1052    }
1053
1054    /// Build a topology from a primary pool only.
1055    #[must_use]
1056    pub const fn primary_only(primary: Pool<RuntimeConnection>) -> Self {
1057        Self {
1058            primary,
1059            replica: None,
1060            migration_url: None,
1061        }
1062    }
1063
1064    /// Attach a runtime-resolved migration URL (see [`Self::migration_url`]).
1065    ///
1066    /// Providers whose primary URL isn't present in the static config — such as
1067    /// the managed-Postgres provider — call this so startup migrations target
1068    /// the pool that was actually built, without publishing the URL to a
1069    /// process-global shared across every app instance.
1070    #[must_use]
1071    pub fn with_migration_url(mut self, url: Option<String>) -> Self {
1072        self.migration_url = url;
1073        self
1074    }
1075
1076    /// The runtime-resolved migration URL, if the provider supplied one.
1077    #[must_use]
1078    pub fn migration_url(&self) -> Option<&str> {
1079        self.migration_url.as_deref()
1080    }
1081
1082    /// Primary/write role pool.
1083    #[must_use]
1084    pub const fn primary(&self) -> &Pool<RuntimeConnection> {
1085        &self.primary
1086    }
1087
1088    /// Optional read/replica role pool.
1089    #[must_use]
1090    pub const fn replica(&self) -> Option<&Pool<RuntimeConnection>> {
1091        self.replica.as_ref()
1092    }
1093
1094    /// Pool used for read-only work.
1095    #[must_use]
1096    pub fn read(&self) -> &Pool<RuntimeConnection> {
1097        self.replica.as_ref().unwrap_or(&self.primary)
1098    }
1099}
1100
1101fn build_pool(
1102    url: &str,
1103    pool_size: usize,
1104    connect_timeout_secs: u64,
1105) -> Result<Pool<RuntimeConnection>, PoolError> {
1106    // Under the `sqlite` feature `RuntimeConnection` is a SQLite connection, so
1107    // the pool must be built over `SyncConnectionWrapper<SqliteConnection>`
1108    // rather than the Postgres manager below. Route to the dedicated SQLite
1109    // builder (PR2, issue #1614). This block is the whole function body under
1110    // the feature (the Postgres arms below are cfg'd out), so it is the tail
1111    // expression — no `return` needed.
1112    #[cfg(feature = "sqlite")]
1113    {
1114        build_sqlite_pool(url, pool_size, connect_timeout_secs)
1115    }
1116
1117    // Default (Postgres) build. A SQLite target is recognized here but its
1118    // runtime pool only exists in a `--features sqlite` build — the pool below
1119    // is a Postgres pool (`Pool<AsyncPgConnection>`). Refuse at pool-build
1120    // (boot) time with an actionable message so a SQLite misconfiguration fails
1121    // fast rather than reaching a confusing first-query failure. A Postgres
1122    // target skips this branch entirely.
1123    #[cfg(not(feature = "sqlite"))]
1124    if crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Sqlite) {
1125        return Err(PoolError::UnsupportedBackend(format!(
1126            "SQLite is a recognized database backend but its runtime pool is only available in \
1127             a build of autumn-web compiled with `--features sqlite`; this is a default \
1128             (Postgres) build (target: {url:?})"
1129        )));
1130    }
1131
1132    #[cfg(not(feature = "sqlite"))]
1133    {
1134        let timeout = Duration::from_secs(connect_timeout_secs);
1135        // When the URL's `sslmode` asks for TLS, plug a rustls-backed connector
1136        // into the pool via a custom setup callback — diesel-async's default
1137        // establish path hardcodes `NoTls`, which cannot satisfy
1138        // `sslmode=require` at all. `sslmode` absent/`disable`/`prefer` keeps the
1139        // default (NoTls) path, so existing configurations behave exactly as
1140        // before. See [`tls`] for the full posture table.
1141        let manager = match tls::TlsPosture::from_database_url(url) {
1142            tls::TlsPosture::Off => AsyncDieselConnectionManager::<AsyncPgConnection>::new(url),
1143            posture => {
1144                let mut config =
1145                    diesel_async::pooled_connection::ManagerConfig::<AsyncPgConnection>::default();
1146                config.custom_setup = tls::setup_callback(posture);
1147                AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(url, config)
1148            }
1149        };
1150        Ok(Pool::builder(manager)
1151            .max_size(pool_size.max(1))
1152            .wait_timeout(Some(timeout))
1153            .create_timeout(Some(timeout))
1154            .runtime(deadpool::Runtime::Tokio1)
1155            .build()?)
1156    }
1157}
1158
1159/// Normalize a configured `SQLite` target into the filename token diesel's
1160/// `SqliteConnection` understands.
1161///
1162/// diesel's `SQLite` backend passes the string straight to `sqlite3_open`, which
1163/// understands a filesystem path, a `file:` URI, or the special `:memory:`
1164/// token — but **not** a `sqlite:` URL scheme. Strip the recognized `SQLite` URL
1165/// spellings down to that: `sqlite::memory:`, `sqlite://:memory:`, and an empty
1166/// `sqlite://` all become an in-memory database; `sqlite:///path` /
1167/// `sqlite://path` / `sqlite:path` reduce to their path; a `file:` URI or a
1168/// bare path passes through unchanged.
1169#[cfg(feature = "sqlite")]
1170fn normalize_sqlite_target(url: &str) -> String {
1171    if url.starts_with("file:") {
1172        return url.to_owned();
1173    }
1174    let rest = url
1175        .strip_prefix("sqlite://")
1176        .or_else(|| url.strip_prefix("sqlite:"))
1177        .unwrap_or(url);
1178    if rest.is_empty() || rest == ":memory:" {
1179        return String::from(":memory:");
1180    }
1181    rest.to_owned()
1182}
1183
1184/// Whether a normalized `SQLite` target names a **private** in-memory database
1185/// (each such connection is its own private database, so the pool must be
1186/// single-slot to stay consistent — see [`build_sqlite_pool`]).
1187///
1188/// Covers every in-memory spelling `SQLite` accepts through this pool: the bare
1189/// `:memory:` token, the `file:` URI form `file::memory:` (with or without a
1190/// query string — addresses Codex P1: the multi-slot default would otherwise
1191/// hand out connections that each see a different, empty in-memory database),
1192/// and any `file:` URI that asks for `mode=memory`.
1193///
1194/// A **shared-cache** in-memory database (`cache=shared`) is the deliberate
1195/// exception: it IS shareable across the pool's connections within one process,
1196/// so it must NOT be forced single-slot and returns `false` here.
1197#[cfg(feature = "sqlite")]
1198fn sqlite_target_is_memory(target: &str) -> bool {
1199    if target.contains("cache=shared") {
1200        return false;
1201    }
1202    target == ":memory:"
1203        || target == "file::memory:"
1204        || target.starts_with("file::memory:?")
1205        || target.contains("mode=memory")
1206}
1207
1208/// Whether a `SQLite` database URL (any accepted spelling) resolves to **any**
1209/// in-memory target — the private spellings (`sqlite::memory:` / `:memory:` /
1210/// `file::memory:`) AND the shared-cache in-memory form
1211/// (`file::memory:?cache=shared`, `file:app?mode=memory&cache=shared`).
1212///
1213/// This is deliberately broader than [`sqlite_target_is_memory`] (the pool
1214/// *sizing* predicate): it does NOT exempt `cache=shared`. It is the predicate
1215/// the startup-migration reject uses, because **no** in-memory target — private
1216/// or shared-cache — can retain a registered migration for the runtime pool. The
1217/// migration runs on a transient synchronous connection; `SQLite` destroys a
1218/// shared in-memory database the moment its *last* connection closes, and the
1219/// runtime deadpool is created lazily (it may not have checked out a connection
1220/// yet), so the pool's first checkout opens a fresh, empty in-memory database and
1221/// every DB-backed request then 500s with "no such table". Only a **file-backed**
1222/// database survives the migration connection closing, so that is the sole
1223/// supported remedy.
1224///
1225/// Pool *sizing* deliberately keeps using [`sqlite_target_is_memory`] instead:
1226/// a shared-cache in-memory database IS shareable across the pool's connections
1227/// within one live process, so it must NOT be forced single-slot. The two
1228/// predicates answer different questions ("share across the pool?" vs. "survive
1229/// the migration connection closing?") and must not be conflated.
1230#[cfg(feature = "sqlite")]
1231pub(crate) fn sqlite_target_is_any_in_memory(url: &str) -> bool {
1232    let target = normalize_sqlite_target(url);
1233    target == ":memory:"
1234        || target == "file::memory:"
1235        || target.starts_with("file::memory:?")
1236        || target.contains("mode=memory")
1237}
1238
1239/// Whether a normalized `SQLite` target names a **read-only** database via its
1240/// URI query string (`mode=ro`, or `immutable=1`/`immutable=true`).
1241///
1242/// A read-only target rejects any write, so the per-connection setup batch must
1243/// skip the write-affecting pragmas (`journal_mode = WAL`) that would otherwise
1244/// fail with "attempt to write a readonly database" and take the whole pool 503
1245/// (see [`build_sqlite_pool`]). The query string is parsed key-by-key
1246/// (case-insensitive keys and values) rather than substring-matched, so an
1247/// unrelated value that merely contains `ro` never trips it.
1248///
1249/// A plain file path, an in-memory target (`mode=memory`, which is not
1250/// read-only), and a `cache=shared` target all return `false`.
1251#[cfg(feature = "sqlite")]
1252fn sqlite_target_is_read_only(target: &str) -> bool {
1253    let Some((_, query)) = target.split_once('?') else {
1254        return false;
1255    };
1256    for pair in query.split('&') {
1257        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
1258        let (key, value) = (key.trim(), value.trim());
1259        if key.eq_ignore_ascii_case("mode") && value.eq_ignore_ascii_case("ro") {
1260            return true;
1261        }
1262        if key.eq_ignore_ascii_case("immutable")
1263            && (value == "1" || value.eq_ignore_ascii_case("true"))
1264        {
1265            return true;
1266        }
1267    }
1268    false
1269}
1270
1271/// Build a deadpool pool over `SyncConnectionWrapper<SqliteConnection>` for a
1272/// `SQLite` target (issue #1614, PR2).
1273///
1274/// `SyncConnectionWrapper` runs synchronous diesel `SqliteConnection` calls on
1275/// Tokio's blocking pool, so this integrates with the async runtime like the
1276/// Postgres path. Pool sizing is deliberately conservative: `SQLite` is
1277/// single-writer, so a large pool only multiplies `SQLITE_BUSY` contention, and
1278/// an in-memory database is **private per connection** — a multi-slot pool over
1279/// `:memory:` would hand out connections that each see a different, empty
1280/// database (so migrations applied on one would be invisible on another).
1281/// In-memory targets are therefore forced to a single slot; file targets
1282/// respect the configured size (still small by convention).
1283#[cfg(feature = "sqlite")]
1284fn build_sqlite_pool(
1285    url: &str,
1286    pool_size: usize,
1287    connect_timeout_secs: u64,
1288) -> Result<Pool<RuntimeConnection>, PoolError> {
1289    // Under the `sqlite` feature the runtime targets SQLite. A Postgres URL here
1290    // is a misconfiguration — refuse with an actionable message rather than
1291    // trying to open a file literally named "postgres://…".
1292    if crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Postgres)
1293    {
1294        return Err(PoolError::UnsupportedBackend(format!(
1295            "this build of autumn-web targets SQLite (compiled with `--features sqlite`) but the \
1296             configured database URL is a Postgres target; configure a `sqlite:` URL instead \
1297             (target: {url:?})"
1298        )));
1299    }
1300
1301    let timeout = Duration::from_secs(connect_timeout_secs);
1302    let target = normalize_sqlite_target(url);
1303    let max_size = if sqlite_target_is_memory(&target) {
1304        1
1305    } else {
1306        pool_size.max(1)
1307    };
1308    // SQLite starts every connection with `foreign_keys` OFF, so a bare manager
1309    // would hand out pooled connections that silently ignore `REFERENCES`
1310    // constraints — orphan rows and referential-integrity violations become
1311    // possible for the whole app (addresses Codex P1). It also uses a default
1312    // busy handler that returns `SQLITE_BUSY` *immediately* when another pooled
1313    // connection holds the single writer lock, so ordinary overlapping writes on
1314    // a >1 slot file pool fail as 5xx instead of waiting briefly for the lock to
1315    // clear (addresses Codex P1). Install both pragmas — plus a deliberate WAL
1316    // journal mode with `synchronous = NORMAL` for better write concurrency —
1317    // during EVERY pooled connection's setup, mirroring how the sync store
1318    // configures its own SQLite connection (see `crate::sync::store`:
1319    // `busy_timeout = 5000`, `journal_mode = WAL`, `synchronous = NORMAL`,
1320    // `foreign_keys = ON`). `busy_timeout` is set FIRST so everything after it
1321    // (and every later query) queues on the timeout instead of failing on a
1322    // held lock; `journal_mode = WAL` is a harmless no-op for a pure `:memory:`
1323    // database. A `custom_setup` callback on the manager runs once per
1324    // newly-created connection, which is exactly the per-connection hook we need
1325    // (the same mechanism the Postgres path uses to install TLS).
1326    //
1327    // A **read-only** URI target (`mode=ro` / `immutable`, e.g.
1328    // `sqlite://file:/srv/reference.db?mode=ro`) is the exception: `journal_mode
1329    // = WAL` writes to the database (it rewrites the file header and creates the
1330    // `-wal`/`-shm` sidecars), so it fails with "attempt to write a readonly
1331    // database" — `custom_setup` would propagate that as a connection-setup
1332    // error and the pool could not service even read-only queries (`Db` routes
1333    // 503). For such targets we install only the non-writing per-connection
1334    // pragmas (`busy_timeout`, `foreign_keys`) and skip the write-affecting
1335    // ones, so a read-only pool builds and serves reads. In-memory targets are
1336    // NOT read-only and keep the full batch.
1337    let mut config = diesel_async::pooled_connection::ManagerConfig::<RuntimeConnection>::default();
1338    config.custom_setup = Box::new(|url: &str| {
1339        use diesel_async::{AsyncConnection as _, SimpleAsyncConnection as _};
1340        let url = url.to_owned();
1341        async move {
1342            let mut conn = RuntimeConnection::establish(&url).await?;
1343            let pragmas = if sqlite_target_is_read_only(&url) {
1344                // Non-writing pragmas only — WAL + synchronous would write and
1345                // fail on a read-only database.
1346                "PRAGMA busy_timeout = 5000; \
1347                 PRAGMA foreign_keys = ON;"
1348            } else {
1349                "PRAGMA busy_timeout = 5000; \
1350                 PRAGMA journal_mode = WAL; \
1351                 PRAGMA synchronous = NORMAL; \
1352                 PRAGMA foreign_keys = ON;"
1353            };
1354            conn.batch_execute(pragmas)
1355                .await
1356                .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
1357            // #1910 FTS5 capability probe. Searchable repositories emit FTS5
1358            // virtual tables + `bm25()` ranking, and the `AddSearch` migration's
1359            // `CREATE VIRTUAL TABLE ... USING fts5(...)` is the hard stop that
1360            // fails loudly at boot if the linked SQLite lacks FTS5. Probe it here
1361            // (create + drop a throwaway FTS5 table in the always-writable `temp`
1362            // database — harmless on read-only main targets) so the failure is a
1363            // clear, actionable diagnostic naming FTS5 and the fix, instead of a
1364            // bare "no such module: fts5" surfacing from a migration. There is NO
1365            // silent fallback to LIKE — full-text search requires FTS5.
1366            if let Err(e) = conn
1367                .batch_execute(
1368                    "CREATE VIRTUAL TABLE temp.__autumn_fts5_probe USING fts5(x); \
1369                     DROP TABLE temp.__autumn_fts5_probe;",
1370                )
1371                .await
1372            {
1373                return Err(diesel::ConnectionError::CouldntSetupConfiguration(
1374                    diesel::result::Error::QueryBuilderError(
1375                        format!(
1376                            "SQLite FTS5 is not available in the linked SQLite library, but \
1377                             autumn-web full-text search (searchable repositories / the \
1378                             `--search` scaffold, issue #1910) requires it. Build with the \
1379                             bundled, FTS5-enabled SQLite by enabling autumn-web's `sqlite` \
1380                             feature (it turns on `libsqlite3-sys/bundled`, whose amalgamation \
1381                             defines SQLITE_ENABLE_FTS5). Underlying probe error: {e}"
1382                        )
1383                        .into(),
1384                    ),
1385                ));
1386            }
1387            Ok(conn)
1388        }
1389        .boxed()
1390    });
1391    let manager =
1392        AsyncDieselConnectionManager::<RuntimeConnection>::new_with_config(target, config);
1393    Ok(Pool::builder(manager)
1394        .max_size(max_size)
1395        .wait_timeout(Some(timeout))
1396        .create_timeout(Some(timeout))
1397        .runtime(deadpool::Runtime::Tokio1)
1398        .build()?)
1399}
1400
1401/// Create a connection pool from the database configuration.
1402///
1403/// Returns `Ok(None)` if no primary database URL is configured
1404/// (`database.primary_url` and the legacy `database.url` are absent or `null`
1405/// in `autumn.toml`).
1406///
1407/// # Errors
1408///
1409/// Returns [`PoolError`] if the pool cannot be built (e.g., invalid
1410/// max-size configuration).
1411pub fn create_pool(config: &DatabaseConfig) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
1412    let Some(url) = config.effective_primary_url() else {
1413        return Ok(None);
1414    };
1415
1416    #[cfg(feature = "sqlite")]
1417    reject_sqlite_statement_timeout(config.statement_timeout)?;
1418
1419    let pool = build_pool(
1420        url,
1421        config.effective_primary_pool_size(),
1422        config.connect_timeout_secs,
1423    )?;
1424
1425    Ok(Some(pool))
1426}
1427
1428/// Reject a configured `database.statement_timeout` under the `SQLite` backend.
1429///
1430/// `SQLite` cannot enforce a per-statement wall-clock timeout through the async
1431/// connection wrapper: diesel's `SqliteConnection` exposes no
1432/// interrupt/progress-handler hook (nor the raw `sqlite3` handle) through
1433/// `SyncConnectionWrapper`, so a runaway query cannot be aborted mid-flight.
1434/// Rather than silently ignore a correctness guarantee we cannot honor, this
1435/// fails the boot fast with an actionable message (fail-closed, issue #1996).
1436///
1437/// A `None` or zero timeout (the default) asks for no guarantee and boots
1438/// cleanly, so ordinary `SQLite` apps are unaffected — only an operator who
1439/// explicitly configured a timeout we cannot meet is stopped. This is the single
1440/// choke point for every pool-construction entry point: the built-in
1441/// `create_pool`, `create_topology`, and `create_shard_topology` factories all
1442/// call it before the pool is built, and `setup_database` calls it once more at
1443/// the pool-provider dispatch boundary so a custom
1444/// [`DatabasePoolProvider`](crate::db::DatabasePoolProvider) — whose
1445/// `create_topology`/`create_shard_topology` need not route through those
1446/// factories — cannot bypass the guard. No configured timeout can reach a live
1447/// `SQLite` pool. `busy_timeout` still bounds lock waits; a real per-statement
1448/// timeout is tracked by #1996/#1910.
1449///
1450/// # Errors
1451///
1452/// Returns [`PoolError::UnsupportedBackend`] when `statement_timeout` is `Some`
1453/// and non-zero.
1454#[cfg(feature = "sqlite")]
1455pub(crate) fn reject_sqlite_statement_timeout(
1456    statement_timeout: Option<Duration>,
1457) -> Result<(), PoolError> {
1458    let Some(timeout) = statement_timeout.filter(|t| !t.is_zero()) else {
1459        return Ok(());
1460    };
1461    Err(PoolError::UnsupportedBackend(format!(
1462        "SQLite backend cannot enforce database.statement_timeout ({}ms): diesel's \
1463         SqliteConnection exposes no interrupt/progress-handler hook through the async \
1464         connection wrapper, so a runaway query cannot be aborted. Unset \
1465         database.statement_timeout for the SQLite backend (busy_timeout already bounds \
1466         lock waits), or run on Postgres. Tracking issue: #1996/#1910.",
1467        timeout.as_millis()
1468    )))
1469}
1470
1471/// Reject a `SQLite` `replica_url` that cannot act as a real read replica for the
1472/// given `primary_url`.
1473///
1474/// Under the `sqlite` feature a configured replica cannot replicate: `SQLite` has
1475/// no primary/replica replication in this pool architecture. Two single-slot
1476/// `:memory:` pools are two *private* empty databases, and a distinct replica
1477/// *file* has nothing replicating into it, so read-routed queries would hit an
1478/// empty replica after writes and schema setup went to the primary. Reject an
1479/// in-memory or distinct-file replica with an actionable boot error (addresses
1480/// Codex P2). A replica that normalizes to the SAME file as the primary is the
1481/// same database — harmless — so it is allowed. `normalize_sqlite_target` is
1482/// reused so "same file" matches how the pool actually opens the file, and
1483/// `sqlite_target_is_memory` so pool sizing and this guard agree on what
1484/// "in-memory" means. Shared by the control-database and per-shard topologies so
1485/// the two rejection rules cannot drift.
1486#[cfg(feature = "sqlite")]
1487fn reject_unusable_sqlite_replica(primary_url: &str, replica_url: &str) -> Result<(), PoolError> {
1488    let replica_target = normalize_sqlite_target(replica_url);
1489    let primary_target = normalize_sqlite_target(primary_url);
1490    if sqlite_target_is_memory(&replica_target) || replica_target != primary_target {
1491        return Err(PoolError::UnsupportedBackend(format!(
1492            "SQLite does not support a separate read replica: replica_url {replica_url:?} \
1493             is in-memory or differs from primary_url {primary_url:?}. Configure only a \
1494             primary, or point the replica at the same database file as the primary."
1495        )));
1496    }
1497    Ok(())
1498}
1499
1500/// Create primary and optional replica pools from the database configuration.
1501///
1502/// Returns `Ok(None)` when neither `database.primary_url` nor the legacy
1503/// `database.url` compatibility field is configured.
1504///
1505/// # Errors
1506///
1507/// Returns [`PoolError`] if either configured role cannot be built.
1508pub fn create_topology(config: &DatabaseConfig) -> Result<Option<DatabaseTopology>, PoolError> {
1509    let Some(primary_url) = config.effective_primary_url() else {
1510        return Ok(None);
1511    };
1512
1513    #[cfg(feature = "sqlite")]
1514    reject_sqlite_statement_timeout(config.statement_timeout)?;
1515
1516    let primary = build_pool(
1517        primary_url,
1518        config.effective_primary_pool_size(),
1519        config.connect_timeout_secs,
1520    )?;
1521
1522    #[cfg(feature = "sqlite")]
1523    if let Some(replica_url) = config.replica_url.as_deref() {
1524        reject_unusable_sqlite_replica(primary_url, replica_url)?;
1525    }
1526
1527    let replica = config
1528        .replica_url
1529        .as_deref()
1530        .map(|url| {
1531            build_pool(
1532                url,
1533                config.effective_replica_pool_size(),
1534                config.connect_timeout_secs,
1535            )
1536        })
1537        .transpose()?;
1538
1539    Ok(Some(DatabaseTopology::from_pools(primary, replica)))
1540}
1541
1542/// Create one shard's primary and optional replica pools, applying the
1543/// shard's pool-size and timeout fallbacks to the `[database]` defaults.
1544///
1545/// # Errors
1546///
1547/// Returns [`PoolError`] if either configured role cannot be built.
1548pub fn create_shard_topology(
1549    shard: &crate::config::ShardConfig,
1550    defaults: &DatabaseConfig,
1551) -> Result<DatabaseTopology, PoolError> {
1552    // A SQLite `[[database.shards]]` deployment reaches this via the ungated
1553    // `sharding::create_shard_set` → `create_shard_topology` path (the
1554    // transactional test-harness variant `create_shard_set_transactional` is
1555    // Postgres-only), so the same fail-closed guard applies per shard. The shard
1556    // inherits the `[database]` `statement_timeout`.
1557    #[cfg(feature = "sqlite")]
1558    reject_sqlite_statement_timeout(defaults.statement_timeout)?;
1559
1560    let primary = build_pool(
1561        &shard.primary_url,
1562        shard.effective_primary_pool_size(defaults),
1563        defaults.connect_timeout_secs,
1564    )?;
1565
1566    // A per-shard `replica_url` is subject to the same SQLite-replica rule as the
1567    // control database: without it a SQLite `[[database.shards]]` entry with a
1568    // distinct or in-memory replica would boot and route reads to an unrelated,
1569    // empty database. Reuse the exact same helper so the two rejections cannot
1570    // drift (addresses Codex P2).
1571    #[cfg(feature = "sqlite")]
1572    if let Some(replica_url) = shard.replica_url.as_deref() {
1573        reject_unusable_sqlite_replica(&shard.primary_url, replica_url)?;
1574    }
1575
1576    let replica = shard
1577        .replica_url
1578        .as_deref()
1579        .map(|url| {
1580            build_pool(
1581                url,
1582                shard.effective_replica_pool_size(defaults),
1583                defaults.connect_timeout_secs,
1584            )
1585        })
1586        .transpose()?;
1587
1588    Ok(DatabaseTopology::from_pools(primary, replica))
1589}
1590
1591// ── Transaction options, retry, and isolation (issue #1202) ──────────────────
1592
1593/// Postgres transaction isolation level, requested per call via [`TxOptions`].
1594///
1595/// `ReadCommitted` is Postgres' default and Autumn's default; the stronger
1596/// levels are opt-in on [`Db::tx_with`]. See the transactions guide for what
1597/// each level buys and costs.
1598#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1599pub enum IsolationLevel {
1600    /// Postgres default. Each statement sees rows committed before it began.
1601    #[default]
1602    ReadCommitted,
1603    /// A snapshot fixed at the first query; no non-repeatable or phantom reads.
1604    RepeatableRead,
1605    /// Full serializability (SSI). Transactions behave as if run one at a time;
1606    /// conflicts surface as `40001` and are retried by [`Db::tx_with`].
1607    Serializable,
1608}
1609
1610impl IsolationLevel {
1611    /// The SQL fragment used in tracing (`db.isolation`).
1612    const fn as_str(self) -> &'static str {
1613        match self {
1614            Self::ReadCommitted => "read_committed",
1615            Self::RepeatableRead => "repeatable_read",
1616            Self::Serializable => "serializable",
1617        }
1618    }
1619}
1620
1621/// Default number of attempts (including the first) for the retrying isolation
1622/// levels. Chosen to match the jobs system's default retry budget.
1623const DEFAULT_TX_MAX_ATTEMPTS: u32 = 5;
1624
1625/// Options for [`Db::tx_with`]: isolation level, access mode, and the automatic
1626/// retry policy for transient serialization failures.
1627///
1628/// [`TxOptions::default`] is byte-for-byte equivalent to today's [`Db::tx`]:
1629/// READ COMMITTED, read-write, a single attempt, and no retry.
1630///
1631/// ```rust
1632/// use autumn_web::db::{TxOptions, IsolationLevel};
1633///
1634/// let opts = TxOptions::serializable().read_only().max_attempts(8);
1635/// assert_eq!(opts.isolation, IsolationLevel::Serializable);
1636/// assert!(opts.read_only);
1637/// assert_eq!(opts.max_attempts, 8);
1638/// ```
1639#[derive(Debug, Clone, Copy)]
1640pub struct TxOptions {
1641    /// Requested isolation level.
1642    pub isolation: IsolationLevel,
1643    /// Run the transaction `READ ONLY`.
1644    pub read_only: bool,
1645    /// Add `DEFERRABLE` (only meaningful for `SERIALIZABLE READ ONLY`).
1646    pub deferrable: bool,
1647    /// Total attempts including the first. `1` disables retry.
1648    pub max_attempts: u32,
1649    /// Backoff before the first retry; doubles each subsequent retry.
1650    pub initial_backoff: Duration,
1651    /// Upper bound on any single backoff delay.
1652    pub max_backoff: Duration,
1653}
1654
1655impl Default for TxOptions {
1656    fn default() -> Self {
1657        Self {
1658            isolation: IsolationLevel::ReadCommitted,
1659            read_only: false,
1660            deferrable: false,
1661            max_attempts: 1,
1662            initial_backoff: Duration::from_millis(5),
1663            max_backoff: Duration::from_millis(500),
1664        }
1665    }
1666}
1667
1668impl TxOptions {
1669    /// READ COMMITTED, no retry — identical to [`Db::tx`].
1670    #[must_use]
1671    pub fn new() -> Self {
1672        Self::default()
1673    }
1674
1675    /// READ COMMITTED (the default), no retry.
1676    #[must_use]
1677    pub fn read_committed() -> Self {
1678        Self {
1679            isolation: IsolationLevel::ReadCommitted,
1680            ..Self::default()
1681        }
1682    }
1683
1684    /// REPEATABLE READ with automatic retry (`serialization_failure` can occur
1685    /// at this level too).
1686    #[must_use]
1687    pub fn repeatable_read() -> Self {
1688        Self {
1689            isolation: IsolationLevel::RepeatableRead,
1690            max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
1691            ..Self::default()
1692        }
1693    }
1694
1695    /// SERIALIZABLE with automatic retry. This is the correctness-critical
1696    /// default: conflicts surface as `40001` and are retried transparently.
1697    #[must_use]
1698    pub fn serializable() -> Self {
1699        Self {
1700            isolation: IsolationLevel::Serializable,
1701            max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
1702            ..Self::default()
1703        }
1704    }
1705
1706    /// Set the isolation level, keeping the other options.
1707    #[must_use]
1708    pub const fn isolation(mut self, level: IsolationLevel) -> Self {
1709        self.isolation = level;
1710        self
1711    }
1712
1713    /// Run the transaction `READ ONLY`.
1714    #[must_use]
1715    pub const fn read_only(mut self) -> Self {
1716        self.read_only = true;
1717        self
1718    }
1719
1720    /// Add `DEFERRABLE` (for `SERIALIZABLE READ ONLY`).
1721    #[must_use]
1722    pub const fn deferrable(mut self) -> Self {
1723        self.deferrable = true;
1724        self
1725    }
1726
1727    /// Set the maximum number of attempts (clamped to at least 1). A value of
1728    /// `1` disables retry.
1729    #[must_use]
1730    pub const fn max_attempts(mut self, attempts: u32) -> Self {
1731        self.max_attempts = if attempts < 1 { 1 } else { attempts };
1732        self
1733    }
1734
1735    /// `max_attempts`, clamped to at least 1.
1736    ///
1737    /// The `max_attempts` field is public (struct-literal update syntax is a
1738    /// supported way to build a `TxOptions`), so a value of `0` can reach the
1739    /// struct without going through [`TxOptions::max_attempts`]'s clamp. The
1740    /// retry loop calls this instead of reading the field directly, so `0`
1741    /// always behaves like `1` (run the closure once, no retry) rather than
1742    /// skipping the closure entirely.
1743    // Only the Postgres retry loop consults this; unused in a `--features
1744    // sqlite` library build (still unit-tested, so keep it compiled).
1745    #[cfg_attr(feature = "sqlite", allow(dead_code))]
1746    const fn effective_max_attempts(&self) -> u32 {
1747        if self.max_attempts < 1 {
1748            1
1749        } else {
1750            self.max_attempts
1751        }
1752    }
1753
1754    /// Set the backoff before the first retry.
1755    #[must_use]
1756    pub const fn initial_backoff(mut self, delay: Duration) -> Self {
1757        self.initial_backoff = delay;
1758        self
1759    }
1760
1761    /// Set the ceiling on any single backoff delay.
1762    #[must_use]
1763    pub const fn max_backoff(mut self, delay: Duration) -> Self {
1764        self.max_backoff = delay;
1765        self
1766    }
1767}
1768
1769/// Whether the retry loop should re-run the closure or return the error.
1770// The whole serialization-failure retry machinery is Postgres-only (see
1771// `Db::tx_with`); these items are unused in a `--features sqlite` library build
1772// but remain unit-tested, so keep them compiled and silence dead-code under the
1773// feature.
1774#[cfg_attr(feature = "sqlite", allow(dead_code))]
1775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1776enum RetryDecision {
1777    Retry,
1778    Stop,
1779}
1780
1781/// Decide whether a just-failed attempt should be retried.
1782///
1783/// `attempt` is the 1-indexed number of the attempt that just failed. A retry
1784/// happens only when the error is retryable **and** attempts remain.
1785#[cfg_attr(feature = "sqlite", allow(dead_code))]
1786const fn retry_decision(attempt: u32, max_attempts: u32, retryable: bool) -> RetryDecision {
1787    if retryable && attempt < max_attempts {
1788        RetryDecision::Retry
1789    } else {
1790        RetryDecision::Stop
1791    }
1792}
1793
1794/// Deterministic capped exponential backoff base: `initial * 2^(attempt-1)`,
1795/// saturating on overflow and capped at `max`.
1796///
1797/// Mirrors the jobs system's backoff shape (`pg_retry_delay_ms`) plus the cap
1798/// idiom from the migrate startup loop. Kept jitter-free so it is unit-testable.
1799#[cfg_attr(feature = "sqlite", allow(dead_code))]
1800fn retry_backoff_base(initial: Duration, max: Duration, attempt: u32) -> Duration {
1801    // Cap the shift so `2^shift` never overflows and huge attempts saturate.
1802    let shift = attempt.saturating_sub(1).min(31);
1803    let multiplier = 1u32 << shift;
1804    initial.checked_mul(multiplier).unwrap_or(max).min(max)
1805}
1806
1807/// [`retry_backoff_base`] with +/-20% jitter applied, never exceeding `max`.
1808///
1809/// Jitter decorrelates a thundering herd of transactions all retrying after the
1810/// same conflict. Delegates to [`crate::cache::jittered_ttl`] (rather than
1811/// re-implementing the RNG/fallback logic) so there's one jitter
1812/// implementation in the crate, including its `getrandom`-failure fallback to
1813/// `SystemTime` entropy instead of silently degrading to no jitter.
1814#[cfg_attr(feature = "sqlite", allow(dead_code))]
1815fn retry_backoff_delay(initial: Duration, max: Duration, attempt: u32) -> Duration {
1816    let base = retry_backoff_base(initial, max, attempt);
1817    crate::cache::jittered_ttl(base, 0.2).min(max)
1818}
1819
1820/// Whether `err` wraps a Postgres serialization failure (`40001`) or deadlock
1821/// (`40P01`) — the two transient errors that are safe to retry by re-running
1822/// the whole transaction.
1823///
1824/// Classification is structural and authoritative, never based on scanning an
1825/// arbitrary error's displayed message: a `diesel::result::Error` anywhere in
1826/// `err`'s source chain (not just the top-level wrapped error — a custom `E`
1827/// that wraps a diesel error via `#[source]`/`#[from]` is still found) is
1828/// classified by its `DatabaseErrorKind`. `diesel-async`'s Postgres backend
1829/// maps `40001` to `SerializationFailure` directly from the real SQLSTATE (see
1830/// `diesel_async::pg::error_helper::from_tokio_postgres_error`) — fully
1831/// reliable, no message inspection involved. Any *other* kind (e.g.
1832/// `UniqueViolation`) is trusted as non-retryable outright — never
1833/// string-matched, so a constraint name or key value that happens to contain
1834/// `"40001"` cannot misclassify it.
1835///
1836/// `40P01` (deadlock) has no dedicated `DatabaseErrorKind` and always surfaces
1837/// as `Unknown`. Worse: the wrapper type diesel-async uses to carry Postgres's
1838/// error fields for an `Unknown`-kind error (`PostgresDbErrorWrapper`, private
1839/// to diesel-async) implements only `DatabaseErrorInformation`, not
1840/// `std::error::Error` — so [`source_chain_has_sqlstate`]'s downcast walk can
1841/// **never** reach the real SQLSTATE for it; there is no structural path to a
1842/// deadlock's code at all through this crate boundary. The only signal
1843/// available is the message, so this checks it — but with an **exact**
1844/// match, not a substring: Postgres's deadlock detector always raises the
1845/// primary message as precisely `"deadlock detected"` with nothing else (the
1846/// context lives in DETAIL/HINT, not here), so an app's own `RAISE EXCEPTION`
1847/// would have to reproduce that exact string as its *entire* message to
1848/// collide — unlike a substring check, which a longer business message
1849/// merely mentioning the phrase would already trip.
1850///
1851/// When no `diesel::result::Error` is found anywhere in the chain, this checks
1852/// for a raw `tokio_postgres` SQLSTATE directly (a custom `E` that bypasses
1853/// diesel). If neither is found, the error is **not** retried — there is
1854/// deliberately no generic message-substring fallback beyond the one exact
1855/// match above: retrying based on bare text risks misclassifying an unrelated
1856/// domain/validation error as a transient conflict, silently re-running a
1857/// non-idempotent closure and delaying the response.
1858#[cfg_attr(feature = "sqlite", allow(dead_code))]
1859fn is_retryable_txn_error(err: &AutumnError) -> bool {
1860    use tokio_postgres::error::SqlState;
1861
1862    fn is_retryable_sqlstate(state: &SqlState) -> bool {
1863        *state == SqlState::T_R_SERIALIZATION_FAILURE || *state == SqlState::T_R_DEADLOCK_DETECTED
1864    }
1865
1866    if let Some(diesel_err) = err.downcast_chain_ref::<diesel::result::Error>() {
1867        return match diesel_err {
1868            // Fast path: diesel-async maps 40001 to SerializationFailure from
1869            // the real SQLSTATE. Fully structural, no message involved.
1870            diesel::result::Error::DatabaseError(
1871                diesel::result::DatabaseErrorKind::SerializationFailure,
1872                _,
1873            ) => true,
1874            // 40P01 (deadlock) has no dedicated kind and surfaces as Unknown.
1875            // The chain walk is kept for forward-compatibility (a future
1876            // diesel/diesel-async release, or another backend, could start
1877            // exposing the real SQLSTATE through the source chain) but is
1878            // currently unreachable for diesel-async's own Postgres backend
1879            // — see the doc comment above. The message check that follows is
1880            // therefore the only thing that actually fires deadlock retries
1881            // today, hence the exact (not substring) match.
1882            diesel::result::Error::DatabaseError(
1883                diesel::result::DatabaseErrorKind::Unknown,
1884                info,
1885            ) => {
1886                source_chain_has_sqlstate(diesel_err, is_retryable_sqlstate)
1887                    || info
1888                        .message()
1889                        .trim()
1890                        .eq_ignore_ascii_case("deadlock detected")
1891            }
1892            // Any other kind (UniqueViolation, NotNullViolation, ...) is
1893            // authoritatively non-retryable — no string matching against its
1894            // message/constraint text.
1895            _ => source_chain_has_sqlstate(diesel_err, is_retryable_sqlstate),
1896        };
1897    }
1898
1899    // No `diesel::result::Error` anywhere in the chain — check for a raw
1900    // `tokio_postgres` SQLSTATE directly. No text-based fallback beyond this;
1901    // see the doc comment above for why.
1902    err.downcast_chain_ref::<tokio_postgres::Error>()
1903        .and_then(tokio_postgres::Error::code)
1904        .is_some_and(is_retryable_sqlstate)
1905        || err
1906            .downcast_chain_ref::<tokio_postgres::error::DbError>()
1907            .is_some_and(|db| is_retryable_sqlstate(db.code()))
1908}
1909
1910/// Run `f` inside a transaction on `conn`, adapting the `ScopedBoxFuture`
1911/// callback shape used throughout Autumn's generated code to the
1912/// `AsyncFnOnce` callback [`diesel_async::AsyncConnection::transaction`]
1913/// expects since diesel-async 0.9.
1914///
1915/// This is a runtime support function for code generated by Autumn proc
1916/// macros. It is semver-exempt; do not call it directly.
1917///
1918/// # Errors
1919///
1920/// Returns the error from `f`, or a `diesel::result::Error` from starting,
1921/// committing, or rolling back the transaction.
1922#[doc(hidden)]
1923pub async fn scoped_transaction<'a, T, E, C, F>(conn: &'a mut C, f: F) -> Result<T, E>
1924where
1925    C: diesel_async::AsyncConnection + Send,
1926    T: Send + 'a,
1927    E: From<diesel::result::Error> + Send + 'a,
1928    F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
1929        + Send
1930        + 'a,
1931{
1932    // Mirrors the default body of `TransactionManager::transaction` in
1933    // diesel-async 0.9, but drives the boxed callback future directly instead
1934    // of going through the `AsyncFnOnce` bounds (which reject the boxed
1935    // `ScopedBoxFuture` callback shape).
1936    use diesel_async::TransactionManager as _;
1937
1938    C::TransactionManager::begin_transaction(conn).await?;
1939    match f(&mut *conn).await {
1940        Ok(value) => {
1941            C::TransactionManager::commit_transaction(conn).await?;
1942            Ok(value)
1943        }
1944        Err(user_error) => match C::TransactionManager::rollback_transaction(conn).await {
1945            // A broken transaction manager means the rollback error is a
1946            // consequence of the original error; surface the original.
1947            Ok(()) | Err(diesel::result::Error::BrokenTransactionManager) => Err(user_error),
1948            Err(rollback_error) => Err(rollback_error.into()),
1949        },
1950    }
1951}
1952
1953/// Run a write read-modify-write closure inside a transaction that takes the
1954/// SQLite write lock up front (`BEGIN IMMEDIATE`).
1955///
1956/// On Postgres this delegates to [`scoped_transaction`] unchanged. On SQLite it
1957/// begins the transaction with `BEGIN IMMEDIATE` before running `f`, so a
1958/// concurrent writer queues on the connection's `busy_timeout` instead of
1959/// failing its deferred read→write snapshot upgrade with `SQLITE_BUSY_SNAPSHOT`
1960/// (which bypasses the busy handler). It is the transaction primitive for
1961/// generated write-RMW paths (`with_lock`, `update`, `delete_by_id`,
1962/// `find_or_create_by`); read-only transactions, [`Db::tx`], and [`savepoint`]
1963/// deliberately stay on the deferred [`scoped_transaction`] so read-only user
1964/// transactions keep their read concurrency.
1965///
1966/// This is a runtime support function for code generated by Autumn proc macros.
1967/// It is semver-exempt; do not call it directly.
1968///
1969/// # SQLite nesting parity
1970///
1971/// The `BEGIN IMMEDIATE` is issued **through** diesel's `AnsiTransactionManager`
1972/// (`begin_transaction_sql`) rather than as a raw statement, so the manager's
1973/// depth counter is synchronized (0 → 1). A nested [`savepoint`] or a
1974/// `TransactionManager`-driven `.transaction()` inside `f` therefore emits a
1975/// `SAVEPOINT` (matching Postgres) instead of a raw `BEGIN` that would fail with
1976/// "cannot start a transaction within a transaction". Commit and rollback also
1977/// route through the manager, so its depth/status bookkeeping stays correct. If
1978/// a `COMMIT` fails, the manager leaves the connection marked in-transaction,
1979/// so deadpool sees a broken transaction manager and discards the connection
1980/// rather than recycling it with an open write transaction.
1981///
1982/// # Errors
1983///
1984/// Returns the error from `f`, or a `diesel::result::Error` from starting or
1985/// committing the transaction. On SQLite a panic inside `f` rolls the
1986/// transaction back (through the transaction manager) before resuming the
1987/// unwind, so the pooled connection is never recycled with an open write
1988/// transaction.
1989#[doc(hidden)]
1990pub async fn scoped_immediate_transaction<'a, T, E, F>(
1991    conn: &'a mut RuntimeConnection,
1992    f: F,
1993) -> Result<T, E>
1994where
1995    T: Send + 'a,
1996    E: From<diesel::result::Error> + Send + 'a,
1997    F: for<'r> FnOnce(
1998            &'r mut RuntimeConnection,
1999        ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2000        + Send
2001        + 'a,
2002{
2003    crate::backend_select! {
2004        pg => { scoped_transaction(conn, f).await },
2005        sqlite => {{
2006            // Begin the immediate transaction THROUGH the transaction manager so
2007            // its depth counter is kept in sync (depth 0 → 1). Reaching the inner
2008            // `SqliteConnection` for `begin_transaction_sql` requires the concrete
2009            // `SyncConnectionWrapper<SqliteConnection>` (`spawn_blocking`), which
2010            // is why this helper takes `&mut RuntimeConnection` rather than a
2011            // generic connection.
2012            use diesel::connection::{AnsiTransactionManager, TransactionManager};
2013
2014            // Take the write lock up front. A concurrent writer queues here on
2015            // `busy_timeout` instead of failing a deferred snapshot upgrade. With
2016            // the depth counter at 1, a nested transaction/savepoint inside `f`
2017            // becomes a SAVEPOINT (Postgres parity) instead of a raw nested BEGIN.
2018            conn.spawn_blocking(|inner| {
2019                AnsiTransactionManager::begin_transaction_sql(inner, "BEGIN IMMEDIATE")
2020            })
2021            .await
2022            .map_err(E::from)?;
2023
2024            // `catch_unwind` is mandatory: a panic that unwound without a rollback
2025            // would leave deadpool free to recycle this connection with an open,
2026            // uncommitted write transaction.
2027            let outcome = AssertUnwindSafe(f(&mut *conn)).catch_unwind().await;
2028            match outcome {
2029                Ok(Ok(value)) => {
2030                    // Commit through the manager. At depth 1 it runs `COMMIT`. If
2031                    // that fails (e.g. a deferred-FK violation) the manager leaves
2032                    // the connection marked in-transaction, so its
2033                    // `is_broken_transaction_manager` reports broken and deadpool
2034                    // discards the connection on return rather than recycling it
2035                    // with an open write transaction — the pool never hands back a
2036                    // dirty connection.
2037                    conn.spawn_blocking(|inner| {
2038                        <AnsiTransactionManager as TransactionManager<
2039                            diesel::SqliteConnection,
2040                        >>::commit_transaction(inner)
2041                    })
2042                    .await
2043                    .map_err(E::from)?;
2044                    Ok(value)
2045                }
2046                Ok(Err(user_error)) => {
2047                    if let Err(e) = conn
2048                        .spawn_blocking(|inner| {
2049                            <AnsiTransactionManager as TransactionManager<
2050                                diesel::SqliteConnection,
2051                            >>::rollback_transaction(inner)
2052                        })
2053                        .await
2054                    {
2055                        tracing::warn!(
2056                            "failed to roll back immediate transaction after error: {e}"
2057                        );
2058                    }
2059                    Err(user_error)
2060                }
2061                Err(panic) => {
2062                    if let Err(e) = conn
2063                        .spawn_blocking(|inner| {
2064                            <AnsiTransactionManager as TransactionManager<
2065                                diesel::SqliteConnection,
2066                            >>::rollback_transaction(inner)
2067                        })
2068                        .await
2069                    {
2070                        tracing::error!(
2071                            "failed to roll back immediate transaction during panic: {e}"
2072                        );
2073                    }
2074                    std::panic::resume_unwind(panic);
2075                }
2076            }
2077        }},
2078    }
2079}
2080
2081/// Run `f` inside a Postgres `SAVEPOINT` on a connection already inside a
2082/// transaction.
2083///
2084/// Pass the `conn` handed to a [`Db::tx`] / [`Db::tx_with`] closure. The
2085/// savepoint is released when `f` returns `Ok`, or rolled back (`ROLLBACK TO
2086/// SAVEPOINT`) when it returns `Err` — leaving the surrounding transaction
2087/// intact.
2088///
2089/// This is the supported way to get a nested, partially-rollbackable unit of
2090/// work: `Db::tx` itself cannot be re-entered on the same connection (its
2091/// closure receives `&mut PooledConnection`, not `&mut Db`), so a same-connection
2092/// savepoint can only live here, inside the closure.
2093///
2094/// # Caveat
2095///
2096/// After-commit callbacks registered inside the savepoint via
2097/// [`register_after_commit`] fire when the **outer** transaction commits,
2098/// regardless of whether this savepoint rolled back — the callback registry is
2099/// transaction-scoped, not savepoint-scoped.
2100///
2101/// # Errors
2102///
2103/// Returns the error from `f`, or a `diesel::result::Error` if the savepoint
2104/// itself cannot be established or released.
2105pub async fn savepoint<'a, C, T, E, F>(conn: &'a mut C, f: F) -> Result<T, E>
2106where
2107    // Generic over the connection so it works with the `&mut PooledConnection`
2108    // handed to a `tx` closure and the `&mut AsyncPgConnection` handed to a
2109    // `tx_with` closure alike.
2110    C: diesel_async::AsyncConnection + Send,
2111    T: Send + 'a,
2112    E: From<diesel::result::Error> + Send + 'a,
2113    F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2114        + Send
2115        + 'a,
2116{
2117    // `scoped_transaction` drives `C::TransactionManager` exactly like
2118    // `conn.transaction` does. diesel-async issues SAVEPOINT (not BEGIN)
2119    // because `conn` is already in a transaction, and RELEASE / ROLLBACK TO on
2120    // Ok / Err respectively.
2121    scoped_transaction(conn, f).await
2122}
2123
2124// ── Db extractor ─────────────────────────────────────────────
2125
2126/// Connection type managed by the deadpool pool.
2127pub type PooledConnection = diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>;
2128
2129struct TxDepthGuard<'a> {
2130    depth: &'a mut usize,
2131    poisoned: &'a mut bool,
2132    disarmed: bool,
2133}
2134
2135impl Drop for TxDepthGuard<'_> {
2136    fn drop(&mut self) {
2137        *self.depth -= 1;
2138        if !self.disarmed {
2139            *self.poisoned = true;
2140        }
2141    }
2142}
2143
2144/// Async database connection extractor.
2145///
2146/// Declare `db: Db` in a handler signature to get a pooled connection to
2147/// Postgres. The connection is returned to the pool when `Db` is dropped
2148/// at the end of the request.
2149///
2150/// `Db` implements [`Deref`](std::ops::Deref) and
2151/// [`DerefMut`](std::ops::DerefMut) to
2152/// `diesel_async::AsyncPgConnection`, so you can use it directly with
2153/// Diesel query methods.
2154///
2155/// If no database is configured (i.e., `database.primary_url` and legacy
2156/// `database.url` are absent),
2157/// requests that use `Db` will receive a `503 Service Unavailable`
2158/// response.
2159///
2160/// # Examples
2161///
2162/// ```rust,no_run
2163/// use autumn_web::prelude::*;
2164///
2165/// #[get("/ping-db")]
2166/// async fn ping_db(db: Db) -> AutumnResult<&'static str> {
2167///     // `db` dereferences to AsyncPgConnection
2168///     Ok("database is reachable")
2169/// }
2170/// ```
2171/// Extension/extractor struct for route-level statement timeout override.
2172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2173pub struct StatementTimeout(pub std::time::Duration);
2174
2175pub struct Db {
2176    conn: PooledConnection,
2177    /// Span covering the full checkout-to-release window. Dropped when
2178    /// `Db` is dropped at the end of the request, so span duration
2179    /// reflects real connection hold time rather than just `pool.get()`
2180    /// latency. Exposed via [`Db::span`] so handlers can attach
2181    /// per-query spans as children with
2182    /// [`tracing::Instrument::instrument`].
2183    span: tracing::Span,
2184    tx_depth: usize,
2185    tx_poisoned: bool,
2186    route_key: Option<String>,
2187    metrics: Option<crate::middleware::MetricsCollector>,
2188    slow_query_threshold: std::time::Duration,
2189    start_time: std::time::Instant,
2190    is_test_tx: bool,
2191}
2192
2193impl Db {
2194    /// Connection-scoped span. Instrument a query future with this to
2195    /// emit a child span tagged under the connection checkout window.
2196    ///
2197    /// ```rust,no_run
2198    /// use autumn_web::prelude::*;
2199    /// use tracing::Instrument as _;
2200    ///
2201    /// # async fn example(mut db: Db) -> AutumnResult<()> {
2202    /// let span = db.span().clone();
2203    /// // run a Diesel query here, e.g. users::table.load(&mut *db)
2204    /// async {
2205    ///     // ... diesel_async query ...
2206    ///     Ok::<_, AutumnError>(())
2207    /// }
2208    /// .instrument(span)
2209    /// .await
2210    /// # }
2211    /// ```
2212    #[must_use]
2213    pub const fn span(&self) -> &tracing::Span {
2214        &self.span
2215    }
2216
2217    /// Run an async closure inside a database transaction at the default
2218    /// isolation level (READ COMMITTED).
2219    ///
2220    /// Commits when the closure returns `Ok(_)`, rolls back when it returns
2221    /// `Err(_)`. For a stronger isolation level and/or automatic
2222    /// serialization-failure retry, use [`Db::tx_with`].
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns [`AutumnError`] when:
2227    ///
2228    /// - the underlying transaction returns an error,
2229    /// - the closure returns an error that converts into `AutumnError`,
2230    /// - this `Db` is already inside a transaction,
2231    /// - this `Db` has been poisoned by a previously cancelled/dropped
2232    ///   transaction future.
2233    ///
2234    /// # Panics
2235    ///
2236    /// Panics if the internal after-commit registry mutex is poisoned (only
2237    /// possible if a previous thread holding the lock panicked).
2238    pub async fn tx<'a, T, E, F>(&'a mut self, f: F) -> Result<T, crate::error::AutumnError>
2239    where
2240        T: Send + 'a,
2241        E: From<diesel::result::Error> + Send + Sync + 'a,
2242        crate::error::AutumnError: From<E>,
2243        F: for<'r> FnOnce(
2244                &'r mut PooledConnection,
2245            ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2246            + Send
2247            + 'a,
2248    {
2249        if self.tx_poisoned {
2250            return Err(crate::error::AutumnError::service_unavailable_msg(
2251                "Database connection is in an invalid transaction state",
2252            ));
2253        }
2254        if self.tx_depth > 0 {
2255            return Err(crate::error::AutumnError::bad_request_msg(
2256                NESTED_TX_MESSAGE,
2257            ));
2258        }
2259        reject_ambient_after_commit_registry_for_tx()?;
2260        self.tx_depth += 1;
2261        let mut guard = TxDepthGuard {
2262            depth: &mut self.tx_depth,
2263            poisoned: &mut self.tx_poisoned,
2264            disarmed: false,
2265        };
2266
2267        // Each tx gets its own callback registry shared with the task-local so
2268        // that code running inside the closure (jobs, mailer, hooks) can push
2269        // callbacks without having access to `Db` directly. The `Arc` lets us
2270        // read the registry after the `scope` future completes.
2271        let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2272
2273        // NOTE: `tx` keeps its own body rather than delegating to `tx_with`
2274        // because the two hand out different connection types. `transaction()`
2275        // runs on the pooled `Object` (closure sees `&mut PooledConnection`),
2276        // whereas `tx_with` must use `build_transaction()` — only available on
2277        // `AsyncPgConnection` — so its closure sees `&mut AsyncPgConnection`.
2278        // `scoped_transaction` adapts the public `ScopedBoxFuture` callback
2279        // shape to the transaction API diesel-async 0.9 expects.
2280        let result = AFTER_COMMIT_REGISTRY
2281            .scope(
2282                registry.clone(),
2283                scoped_transaction::<T, E, _, _>(&mut self.conn, f),
2284            )
2285            .await
2286            .map_err(Into::into);
2287
2288        guard.disarmed = true;
2289
2290        // On commit: spawn the registered callbacks outside the transaction
2291        // connection, but await them sequentially inside that task so callback
2292        // dependencies observe registration order.
2293        // Errors are counted and logged; they do NOT affect the committed tx.
2294        // In transactional tests (outer transaction is rolled back), we suppress
2295        // spawning these callbacks to prevent observing uncommitted side effects.
2296        if result.is_ok() {
2297            let callbacks: Vec<CommitCallback> = {
2298                let mut reg = registry.lock().expect("registry lock");
2299                std::mem::take(&mut *reg)
2300            };
2301
2302            if !callbacks.is_empty() && !self.is_test_tx {
2303                let _ = spawn_committed_after_commit_callbacks(callbacks);
2304            }
2305        }
2306
2307        result
2308    }
2309
2310    /// Run an async closure inside a database transaction with explicit
2311    /// [`TxOptions`] — isolation level, read-only/deferrable mode, and automatic
2312    /// retry of transient serialization failures (`40001`) and deadlocks
2313    /// (`40P01`).
2314    ///
2315    /// Commits when the closure returns `Ok(_)`, rolls back when it returns
2316    /// `Err(_)`. On a retryable failure with attempts remaining, the whole
2317    /// closure is re-run after a capped exponential backoff with jitter.
2318    ///
2319    /// # The closure must be re-runnable
2320    ///
2321    /// **Because the closure can run more than once, it must be free of
2322    /// side effects that are not themselves transactional (or must be
2323    /// idempotent).** Database work is rolled back between attempts, and
2324    /// after-commit callbacks from failed attempts are discarded — but any
2325    /// non-database side effect in the closure body (logging aside — external
2326    /// API calls, channel sends, in-memory mutation) will re-execute on each
2327    /// retry. Keep such effects out of the closure, or gate them on the final
2328    /// success.
2329    ///
2330    /// # Observability
2331    ///
2332    /// The transaction runs under a `db.transaction` span carrying
2333    /// `db.isolation` and the final `db.tx.attempts` count. Each retry also
2334    /// increments [`TX_RETRIES_TOTAL`]; an exhausted retry budget increments
2335    /// [`TX_RETRY_EXHAUSTED_TOTAL`].
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns [`AutumnError`] under the same conditions as [`Db::tx`]. A
2340    /// non-retryable error is returned immediately; when the retry budget is
2341    /// exhausted, the **final** underlying error is returned (never swallowed).
2342    ///
2343    /// Under the `sqlite` feature there is no transaction builder that can
2344    /// enforce `READ ONLY`, so a request carrying [`TxOptions::read_only`]
2345    /// returns an unsupported-options error **before the closure runs** rather
2346    /// than silently executing a writable transaction (which would let writes
2347    /// commit under a read-only contract). A normal read-write transaction is
2348    /// unaffected. The Postgres path enforces read-only via the transaction
2349    /// builder and never rejects.
2350    ///
2351    /// # Panics
2352    ///
2353    /// Panics if the internal after-commit registry mutex is poisoned (only
2354    /// possible if a previous thread holding the lock panicked).
2355    #[allow(clippy::too_many_lines)]
2356    // The Postgres path re-borrows `f` per retry attempt (`&mut f`); the SQLite
2357    // path consumes it once, so `mut` is unused there.
2358    #[cfg_attr(feature = "sqlite", allow(unused_mut))]
2359    pub async fn tx_with<'a, T, E, F>(
2360        &'a mut self,
2361        opts: TxOptions,
2362        mut f: F,
2363    ) -> Result<T, crate::error::AutumnError>
2364    where
2365        T: Send + 'a,
2366        E: From<diesel::result::Error> + Send + Sync + 'a,
2367        crate::error::AutumnError: From<E>,
2368        // The closure receives `&mut RuntimeConnection` (not `&mut PooledConnection`
2369        // as `tx` does): on Postgres, isolation levels require `build_transaction()`,
2370        // which is inherent on `AsyncPgConnection` (the default `RuntimeConnection`).
2371        // Diesel query methods work on either backend. Under the `sqlite` feature
2372        // `RuntimeConnection` is a SQLite connection and the isolation/retry path
2373        // below degrades to a single plain transaction.
2374        F: for<'r> FnMut(
2375                &'r mut RuntimeConnection,
2376            ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2377            + Send
2378            + 'a,
2379    {
2380        if self.tx_poisoned {
2381            return Err(crate::error::AutumnError::service_unavailable_msg(
2382                "Database connection is in an invalid transaction state",
2383            ));
2384        }
2385        if self.tx_depth > 0 {
2386            return Err(crate::error::AutumnError::bad_request_msg(
2387                NESTED_TX_MESSAGE,
2388            ));
2389        }
2390        reject_ambient_after_commit_registry_for_tx()?;
2391
2392        // Under the SQLite runtime there is no transaction builder that can
2393        // enforce `READ ONLY` semantics — the `sqlite` arm below runs a single
2394        // plain, writable transaction. Silently honoring a caller's
2395        // `TxOptions::read_only()` by running a writable transaction anyway would
2396        // let writes succeed and commit under a contract that promised none — a
2397        // safety regression for any code relying on a read-only transaction to
2398        // prevent mutation. So reject the request up front, BEFORE the closure
2399        // can run, rather than pretending to honor it. (Real `query_only`
2400        // enforcement is avoided deliberately: deadpool's `custom_setup` runs on
2401        // CREATE only, so a leaked `PRAGMA query_only = ON` would poison a pooled
2402        // connection for its lifetime.) The Postgres path enforces read-only via
2403        // the transaction builder's `read_only()` and is unaffected.
2404        #[cfg(feature = "sqlite")]
2405        if opts.read_only {
2406            return Err(crate::error::AutumnError::bad_request_msg(
2407                "SQLite runtime does not support read-only transactions \
2408                 (TxOptions::read_only); this build cannot enforce read-only \
2409                 semantics on SQLite. Remove the read_only option or run on \
2410                 Postgres.",
2411            ));
2412        }
2413
2414        self.tx_depth += 1;
2415        let mut guard = TxDepthGuard {
2416            depth: &mut self.tx_depth,
2417            poisoned: &mut self.tx_poisoned,
2418            disarmed: false,
2419        };
2420
2421        let span = tracing::info_span!(
2422            "db.transaction",
2423            db.system = "postgresql",
2424            db.isolation = opts.isolation.as_str(),
2425            db.tx.attempts = tracing::field::Empty,
2426        );
2427
2428        if self.is_test_tx {
2429            // Under a transactional `TestApp` the connection is already inside
2430            // the test harness's outer transaction (`begin_test_transaction`),
2431            // so issuing a literal `BEGIN`/`SET TRANSACTION ISOLATION LEVEL`
2432            // via `build_transaction()` here would be invalid — Postgres
2433            // rejects `SET TRANSACTION ISOLATION LEVEL` inside a
2434            // subtransaction, and the retry loop's per-attempt `&mut f`
2435            // re-borrow doesn't type-check against the plain `transaction()`
2436            // method's lifetime shape (unlike `build_transaction().run()`, its
2437            // bound is not scoped to a single call). So: nest via `SAVEPOINT`
2438            // instead, exactly like `Db::tx`, running the closure exactly
2439            // once — the requested isolation/read-only/deferrable/retry
2440            // options are inherited from (or meaningless nested inside) the
2441            // outer test transaction, so there is nothing to retry against a
2442            // single test-harness connection.
2443            let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2444            let conn: &mut RuntimeConnection = &mut self.conn;
2445            let result = AFTER_COMMIT_REGISTRY
2446                .scope(registry, scoped_transaction::<T, E, _, _>(conn, f))
2447                .instrument(span.clone())
2448                .await
2449                .map_err(Into::into);
2450
2451            span.record("db.tx.attempts", 1u32);
2452            guard.disarmed = true;
2453            // `is_test_tx` always suppresses after-commit spawning (matching
2454            // `Db::tx`), so the registry is simply dropped without draining.
2455            return result;
2456        }
2457
2458        // SQLite has no `SET TRANSACTION ISOLATION LEVEL` / read-only /
2459        // deferrable transaction builder and no serialization-failure retry
2460        // semantics, so run the closure once inside a single plain transaction.
2461        // The requested isolation level, read-only, deferrable, and retry
2462        // options are Postgres-only and are ignored here (documented).
2463        // After-commit callbacks still fire on commit, exactly as on the
2464        // Postgres path.
2465        #[cfg(feature = "sqlite")]
2466        {
2467            let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2468            let conn: &mut RuntimeConnection = &mut self.conn;
2469            let result: Result<T, E> = AFTER_COMMIT_REGISTRY
2470                .scope(registry.clone(), scoped_transaction::<T, E, _, _>(conn, f))
2471                .instrument(span.clone())
2472                .await;
2473            span.record("db.tx.attempts", 1u32);
2474            guard.disarmed = true;
2475            // This block is the whole remaining function body under the feature
2476            // (the Postgres retry loop below is cfg'd out), so the `match` is the
2477            // tail expression — no `return` needed.
2478            match result {
2479                Ok(value) => {
2480                    let callbacks: Vec<CommitCallback> = {
2481                        let mut reg = registry.lock().expect("registry lock");
2482                        std::mem::take(&mut *reg)
2483                    };
2484                    if !callbacks.is_empty() {
2485                        let _ = spawn_committed_after_commit_callbacks(callbacks);
2486                    }
2487                    Ok(value)
2488                }
2489                Err(user_error) => Err(crate::error::AutumnError::from(user_error)),
2490            }
2491        }
2492
2493        #[cfg(not(feature = "sqlite"))]
2494        {
2495            let max_attempts = opts.effective_max_attempts();
2496            let mut attempt: u32 = 0;
2497
2498            let outcome: Result<T, crate::error::AutumnError> = loop {
2499                attempt += 1;
2500
2501                // A fresh registry per attempt: a rolled-back attempt's after-commit
2502                // callbacks are discarded simply by dropping this `Arc` undrained.
2503                let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2504
2505                let attempt_result: Result<T, E> = {
2506                    // *** LOAD-BEARING ORDER: borrow `f` BEFORE `build_transaction()`.
2507                    // diesel-async's `TransactionBuilder::run` bounds the closure by
2508                    // the connection-borrow lifetime; `&mut f`'s region must start
2509                    // earlier than that borrow or it fails to compile. Do not reorder.
2510                    let f_ref = &mut f;
2511
2512                    let mut builder = self.conn.build_transaction();
2513                    builder = match opts.isolation {
2514                        IsolationLevel::ReadCommitted => builder.read_committed(),
2515                        IsolationLevel::RepeatableRead => builder.repeatable_read(),
2516                        IsolationLevel::Serializable => builder.serializable(),
2517                    };
2518                    if opts.read_only {
2519                        builder = builder.read_only();
2520                    }
2521                    if opts.deferrable {
2522                        builder = builder.deferrable();
2523                    }
2524
2525                    AFTER_COMMIT_REGISTRY
2526                        .scope(
2527                            registry.clone(),
2528                            builder.run::<T, E, _>(async move |conn| f_ref(conn).await),
2529                        )
2530                        .instrument(span.clone())
2531                        .await
2532                };
2533
2534                match attempt_result {
2535                    Ok(value) => {
2536                        // Commit path: drain THIS attempt's callbacks and spawn
2537                        // them. (The `is_test_tx` case already returned above, so
2538                        // spawning here is always live.)
2539                        let callbacks: Vec<CommitCallback> = {
2540                            let mut reg = registry.lock().expect("registry lock");
2541                            std::mem::take(&mut *reg)
2542                        };
2543                        if !callbacks.is_empty() {
2544                            let _ = spawn_committed_after_commit_callbacks(callbacks);
2545                        }
2546                        break Ok(value);
2547                    }
2548                    Err(e) => {
2549                        // Convert to AutumnError first, then classify on it.
2550                        let ae = crate::error::AutumnError::from(e);
2551                        let retryable = is_retryable_txn_error(&ae);
2552                        match retry_decision(attempt, max_attempts, retryable) {
2553                            RetryDecision::Retry => {
2554                                let retries_total = record_tx_retry();
2555                                let delay = retry_backoff_delay(
2556                                    opts.initial_backoff,
2557                                    opts.max_backoff,
2558                                    attempt,
2559                                );
2560                                tracing::debug!(
2561                                    parent: &span,
2562                                    attempt,
2563                                    max_attempts,
2564                                    delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
2565                                    autumn.tx.retries_total = retries_total,
2566                                    "retrying transaction after serialization/deadlock failure"
2567                                );
2568                                // `registry` drops here → rolled-back attempt's
2569                                // after-commit callbacks are discarded; the loop
2570                                // then re-runs the closure.
2571                                tokio::time::sleep(delay).await;
2572                            }
2573                            RetryDecision::Stop => {
2574                                if retryable {
2575                                    let exhausted_total = record_tx_retry_exhausted();
2576                                    tracing::warn!(
2577                                        parent: &span,
2578                                        attempt,
2579                                        max_attempts,
2580                                        autumn.tx.retry_exhausted_total = exhausted_total,
2581                                        "transaction retry budget exhausted; returning final error"
2582                                    );
2583                                }
2584                                break Err(ae);
2585                            }
2586                        }
2587                    }
2588                }
2589            };
2590
2591            span.record("db.tx.attempts", attempt);
2592            guard.disarmed = true;
2593            outcome
2594        }
2595    }
2596}
2597
2598impl std::ops::Deref for Db {
2599    type Target = RuntimeConnection;
2600    fn deref(&self) -> &Self::Target {
2601        assert!(
2602            !self.tx_poisoned,
2603            "Db connection is poisoned due to a cancelled/dropped transaction"
2604        );
2605        &self.conn
2606    }
2607}
2608
2609impl std::ops::DerefMut for Db {
2610    fn deref_mut(&mut self) -> &mut Self::Target {
2611        assert!(
2612            !self.tx_poisoned,
2613            "Db connection is poisoned due to a cancelled/dropped transaction"
2614        );
2615        &mut self.conn
2616    }
2617}
2618
2619/// Everything required to check out and instrument a pooled connection.
2620///
2621/// Shared by the plain [`Db`] extractor and shard-routed checkouts so that
2622/// every connection — regardless of which pool it came from — gets the same
2623/// span, interceptor, statement-timeout, and slow-query treatment.
2624pub(crate) struct DbCheckoutParams<'a> {
2625    /// Pool to check the connection out of.
2626    pub pool: &'a Pool<RuntimeConnection>,
2627    /// Role label surfaced to [`DbConnectionInterceptor`]s, e.g. `"primary"`
2628    /// or `"shard:<name>:primary"`.
2629    pub pool_name: &'a str,
2630    /// Shard name recorded on the `db.connection` span, when routed.
2631    pub shard: Option<&'a str>,
2632    /// Resolved statement timeout (route override already merged with the
2633    /// global config). `None` disables the timeout (`SET statement_timeout = 0`).
2634    pub statement_timeout: Option<std::time::Duration>,
2635    /// `"METHOD /matched/path"` key used for per-route DB metrics.
2636    pub route_key: Option<String>,
2637    pub metrics: Option<crate::middleware::MetricsCollector>,
2638    pub slow_query_threshold: std::time::Duration,
2639    pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
2640}
2641
2642impl Db {
2643    /// Check a connection out of `params.pool` with full instrumentation.
2644    ///
2645    /// This is the single code path behind the [`Db`] extractor and all
2646    /// shard-routed checkouts: span creation, checkout interceptors,
2647    /// `SET statement_timeout`, and the metrics captured for the
2648    /// slow-query warning on `Drop`.
2649    pub(crate) async fn checkout(params: DbCheckoutParams<'_>) -> Result<Self, AutumnError> {
2650        // `SET statement_timeout` (and its i32-cap arithmetic below) is a
2651        // Postgres session GUC. Under the `sqlite` feature the runtime backend
2652        // is entirely SQLite (the `RuntimeConnection` alias flips wholesale —
2653        // see `build_sqlite_pool`), which rejects the statement and would turn
2654        // every `Db`-using route into a 503. The const, the `RunQueryDsl`
2655        // import, the timeout arithmetic, and the `SET` itself are therefore all
2656        // gated off on the SQLite build; the Postgres path is byte-identical.
2657        #[cfg(not(feature = "sqlite"))]
2658        const PG_TIMEOUT_MAX_MS: u64 = i32::MAX as u64;
2659        #[cfg(not(feature = "sqlite"))]
2660        use diesel_async::RunQueryDsl as _;
2661
2662        // Span covers the full time the connection is held — from
2663        // checkout through the end of the request — rather than just
2664        // `pool.get()`. Dropping `Db` closes the span, so span duration
2665        // reflects real connection hold time and `db.system=postgresql`
2666        // propagates to any query futures handlers instrument with
2667        // `db.span()`.
2668        let span = tracing::info_span!(
2669            "db.connection",
2670            otel.kind = "client",
2671            db.system = "postgresql",
2672            db.shard = tracing::field::Empty,
2673        );
2674        if let Some(shard) = params.shard {
2675            span.record("db.shard", shard);
2676        }
2677
2678        let pool = params.pool;
2679        let mut checkout_future: std::pin::Pin<
2680            Box<
2681                dyn std::future::Future<Output = Result<PooledConnection, AutumnError>> + Send + '_,
2682            >,
2683        > = Box::pin(async move {
2684            pool.get().await.map_err(|e| {
2685                tracing::error!("Failed to acquire database connection: {e}");
2686                AutumnError::service_unavailable_msg(e.to_string())
2687            })
2688        });
2689        for interceptor in &params.interceptors {
2690            let ctx = crate::interceptor::DbCheckoutContext {
2691                pool_name: params.pool_name.to_string(),
2692            };
2693            checkout_future = interceptor.intercept_checkout(ctx, checkout_future);
2694        }
2695
2696        let mut conn = checkout_future.instrument(span.clone()).await?;
2697
2698        // `statement_timeout` is a Postgres session GUC; it is intentionally
2699        // unused on the SQLite backend (see the gating note below), so consume
2700        // it here to keep the shared `DbCheckoutParams` field from reading as
2701        // dead code under `--features sqlite`.
2702        #[cfg(feature = "sqlite")]
2703        let _ = params.statement_timeout;
2704
2705        // Postgres statement_timeout is a signed 32-bit integer (milliseconds).
2706        // Cap at i32::MAX to avoid a confusing 503 for very large configured values.
2707        #[cfg(not(feature = "sqlite"))]
2708        let timeout_ms = params.statement_timeout.map_or(0u64, |d| {
2709            u64::try_from(d.as_millis())
2710                .unwrap_or(PG_TIMEOUT_MAX_MS)
2711                .min(PG_TIMEOUT_MAX_MS)
2712        });
2713
2714        // Install a fresh per-request query timer, but ONLY when a query
2715        // observer is active — EITHER a `REQUEST_DB_TIMINGS` scope (the
2716        // `ServerTimingLayer`, enabled by `[observability] server_timing`) OR a
2717        // `REQUEST_QUERY_CAPTURE` scope (the test harness capturing the SQL
2718        // list, which runs with `server_timing` off). The timer feeds both
2719        // lanes via `record_request_db_query`. Installed BEFORE the `SET
2720        // statement_timeout` housekeeping statement below.
2721        //
2722        // `set_instrumentation` WHOLESALE REPLACES the connection's
2723        // instrumentation, so it must not run unconditionally: an application
2724        // that registered a global default via
2725        // `diesel::connection::set_default_instrumentation` (query logging,
2726        // tracing, metrics) would have it silently clobbered on the first
2727        // checkout and never restored — even when `server_timing` is disabled.
2728        // Gating on `request_db_timing_active() || request_query_capture_active()`
2729        // preserves the app's instrumentation whenever neither lane is scoped
2730        // (no `server_timing`, no test capture — the production default), and
2731        // only overwrites it for the duration a query observer is active.
2732        //
2733        // Installing a *fresh* timer on every observed checkout also clears any
2734        // stale `RequestQueryTimer` a pooled connection carried from a prior
2735        // request (diesel-async's deadpool manager never resets instrumentation
2736        // on recycle), so a stale timer can never record the upcoming
2737        // housekeeping `SET` (or later app queries) into a *different* request's
2738        // accumulator. Installing BEFORE the `SET` (which
2739        // `is_uncounted_statement` classifies as housekeeping) keeps that
2740        // statement out of the `Server-Timing` `db` count regardless. Any stale
2741        // timer left on a connection later reused by an opted-out request is a
2742        // cheap no-op: `on_start` probes both lanes before formatting or
2743        // recording anything, so it never allocates off-scope.
2744        #[cfg(feature = "db")]
2745        {
2746            use diesel_async::AsyncConnection as _;
2747            if request_db_timing_active() || request_query_capture_active() {
2748                conn.set_instrumentation(RequestQueryTimer::default());
2749            }
2750        }
2751
2752        // Postgres-only per-checkout initialization; see the gating note above.
2753        // SQLite builds skip it entirely (it would 503 every `Db`-using route).
2754        #[cfg(not(feature = "sqlite"))]
2755        diesel::sql_query(format!("SET statement_timeout = {timeout_ms}"))
2756            .execute(&mut conn)
2757            .await
2758            .map_err(|e| {
2759                tracing::error!("Failed to set database statement_timeout to {timeout_ms}ms: {e}");
2760                AutumnError::service_unavailable_msg(format!("Database initialization error: {e}"))
2761            })?;
2762
2763        let start_time = std::time::Instant::now();
2764        let is_test_tx = params
2765            .interceptors
2766            .iter()
2767            .any(|i| i.is_transactional_test());
2768
2769        Ok(Self {
2770            conn,
2771            span,
2772            tx_depth: 0,
2773            tx_poisoned: false,
2774            route_key: params.route_key,
2775            metrics: params.metrics,
2776            slow_query_threshold: params.slow_query_threshold,
2777            start_time,
2778            is_test_tx,
2779        })
2780    }
2781
2782    /// Check a plain, uninstrumented connection out of `pool` for use in tests.
2783    ///
2784    /// Unlike the request extractor, this applies no interceptors, statement
2785    /// timeout, or route metrics — it exists so integration tests can drive
2786    /// [`Db::tx`] / [`Db::tx_with`] directly against a [`crate::test::TestDb`]
2787    /// pool without spinning up a full `TestApp`.
2788    ///
2789    /// # Errors
2790    ///
2791    /// Returns [`AutumnError`] if a connection cannot be acquired from `pool`.
2792    #[cfg(feature = "test-support")]
2793    pub async fn connect_for_test(pool: &Pool<RuntimeConnection>) -> Result<Self, AutumnError> {
2794        Self::checkout(DbCheckoutParams {
2795            pool,
2796            pool_name: "test",
2797            shard: None,
2798            statement_timeout: None,
2799            route_key: None,
2800            metrics: None,
2801            slow_query_threshold: std::time::Duration::from_millis(500),
2802            interceptors: Vec::new(),
2803        })
2804        .await
2805    }
2806}
2807
2808/// Request-derived context shared by every `Db`-producing extractor.
2809///
2810/// Captures the route-override statement timeout, the matched-path metrics
2811/// key, and the state-held instrumentation handles so shard-routed
2812/// checkouts behave identically to the plain [`Db`] extractor.
2813#[derive(Clone)]
2814pub(crate) struct RequestDbContext {
2815    pub statement_timeout: Option<std::time::Duration>,
2816    pub route_key: Option<String>,
2817    pub metrics: Option<crate::middleware::MetricsCollector>,
2818    pub slow_query_threshold: std::time::Duration,
2819    pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
2820}
2821
2822impl RequestDbContext {
2823    pub(crate) fn from_parts<S: DbState>(parts: &axum::http::request::Parts, state: &S) -> Self {
2824        let timeout_override = parts.extensions.get::<StatementTimeout>().copied();
2825        let matched_path = parts
2826            .extensions
2827            .get::<axum::extract::MatchedPath>()
2828            .map_or_else(|| parts.uri.path(), axum::extract::MatchedPath::as_str);
2829        Self {
2830            statement_timeout: timeout_override
2831                .map(|t| t.0)
2832                .or_else(|| state.statement_timeout()),
2833            route_key: Some(format!("{} {}", parts.method, matched_path)),
2834            metrics: state.metrics().cloned(),
2835            slow_query_threshold: state.slow_query_threshold(),
2836            interceptors: state.db_interceptors(),
2837        }
2838    }
2839}
2840
2841impl<S> FromRequestParts<S> for Db
2842where
2843    S: DbState + Send + Sync,
2844{
2845    type Rejection = AutumnError;
2846
2847    async fn from_request_parts(
2848        parts: &mut axum::http::request::Parts,
2849        state: &S,
2850    ) -> Result<Self, Self::Rejection> {
2851        let pool = state
2852            .pool()
2853            .ok_or_else(|| AutumnError::service_unavailable_msg("Database not configured"))?;
2854        let ctx = RequestDbContext::from_parts(parts, state);
2855
2856        let result = Self::checkout(DbCheckoutParams {
2857            pool,
2858            pool_name: "primary",
2859            shard: None,
2860            statement_timeout: ctx.statement_timeout,
2861            route_key: ctx.route_key,
2862            metrics: ctx.metrics,
2863            slow_query_threshold: ctx.slow_query_threshold,
2864            interceptors: ctx.interceptors,
2865        })
2866        .await;
2867        // Notify the RYWW task-local that a primary connection was checked out.
2868        // No-op when read_your_writes = "off" (task-local absent).
2869        if result.is_ok() {
2870            crate::read_your_writes::mark_write();
2871        }
2872        result
2873    }
2874}
2875
2876impl Drop for Db {
2877    fn drop(&mut self) {
2878        if let (Some(route_key), Some(metrics)) = (&self.route_key, &self.metrics) {
2879            let elapsed = self.start_time.elapsed();
2880            let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
2881
2882            // Record DB query metric
2883            let metric_key = format!("{route_key} SELECT");
2884            metrics.record_db_query(&metric_key, elapsed_ms);
2885            // NOTE: deliberately *not* recorded into the Server-Timing
2886            // per-request accumulator. `elapsed` here is the whole
2887            // connection checkout-to-release window (see `start_time` /
2888            // the `span` doc above), not a single query's wall time. Every
2889            // request that extracts `Db` would otherwise add its entire
2890            // connection-hold time as one bogus "query", inflating both
2891            // `db;dur` and the `desc="N queries"` count (and double-counting
2892            // against real queries recorded by `run_instrumented`). The
2893            // accumulator must reflect only genuine instrumented queries.
2894
2895            // Log slow query if it exceeds the threshold
2896            if elapsed >= self.slow_query_threshold {
2897                tracing::warn!(
2898                    route = %route_key,
2899                    sql = "SELECT ?",
2900                    duration_ms = elapsed_ms,
2901                    "slow database query"
2902                );
2903            }
2904        }
2905    }
2906}
2907
2908// ----------------------------------------------------------------------------
2909// DatabasePoolProvider — tier-1 boot-time replaceable pool factory
2910// ----------------------------------------------------------------------------
2911
2912/// Pluggable boot-time database pool factory.
2913///
2914/// Replace the default `deadpool + diesel-async` factory with a custom
2915/// strategy (custom metrics wrapper, circuit breaker, separate pools per
2916/// shard, etc.) by implementing this trait and installing it on the
2917/// [`AppBuilder`](crate::app::AppBuilder) via
2918/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
2919///
2920/// The trait abstracts the *factory*, not the pool *type* — the return type is
2921/// fixed at `Pool<AsyncPgConnection>` for now. Swapping to a different backend
2922/// (e.g. `MySQL`, `SQLite`) would require generic `Pool<C>` propagation through
2923/// `Db` / `DbState` / `AppState` and is intentionally out of scope.
2924///
2925/// Providers that only implement [`DatabasePoolProvider::create_pool`] still
2926/// participate in primary/replica topology: the default
2927/// [`DatabasePoolProvider::create_topology`] uses the custom primary pool and
2928/// builds the configured replica role with Autumn's deadpool factory. Override
2929/// `create_topology` when both roles need custom construction.
2930///
2931/// # Example
2932///
2933/// ```rust,no_run
2934/// use autumn_web::config::DatabaseConfig;
2935/// use autumn_web::db::{DatabasePoolProvider, PoolError};
2936/// use diesel_async::AsyncPgConnection;
2937/// use diesel_async::pooled_connection::deadpool::Pool;
2938///
2939/// pub struct MetricsPoolProvider;
2940///
2941/// impl DatabasePoolProvider for MetricsPoolProvider {
2942///     async fn create_pool(
2943///         &self,
2944///         config: &DatabaseConfig,
2945///     ) -> Result<Option<Pool<AsyncPgConnection>>, PoolError> {
2946///         // Wrap the default pool with custom metrics, then return it.
2947///         autumn_web::db::create_pool(config)
2948///     }
2949/// }
2950/// ```
2951pub trait DatabasePoolProvider: Send + Sync + 'static {
2952    /// Create a connection pool from the resolved [`DatabaseConfig`].
2953    ///
2954    /// Returning `Ok(None)` signals that the application should run without a
2955    /// database — useful for static-site / API-gateway use cases or for
2956    /// disabling the DB in test contexts.
2957    fn create_pool(
2958        &self,
2959        config: &DatabaseConfig,
2960    ) -> impl std::future::Future<Output = Result<Option<Pool<RuntimeConnection>>, PoolError>> + Send;
2961
2962    /// Create primary and optional replica pools from the resolved
2963    /// [`DatabaseConfig`].
2964    ///
2965    /// The default implementation preserves the provider's custom primary pool
2966    /// and builds a replica pool when `database.replica_url` is configured.
2967    ///
2968    /// # Errors
2969    ///
2970    /// Returns [`PoolError`] if either configured role cannot be built.
2971    fn create_topology(
2972        &self,
2973        config: &DatabaseConfig,
2974    ) -> impl std::future::Future<Output = Result<Option<DatabaseTopology>, PoolError>> + Send {
2975        async move {
2976            let Some(primary) = self.create_pool(config).await? else {
2977                return Ok(None);
2978            };
2979
2980            // A custom provider that overrides only `create_pool` still gets its
2981            // replica built by this default here, so the SQLite-replica rule must
2982            // be enforced on this path too -- otherwise a provider configuring a
2983            // distinct/in-memory `database.replica_url` would boot two unrelated
2984            // SQLite databases and route reads to an empty/stale replica. Reuse
2985            // the same helper `create_topology`/`create_shard_topology` use so the
2986            // rejection rule cannot drift (addresses Codex P2). The primary URL is
2987            // whatever `effective_primary_url` resolves; a provider returning a
2988            // pool for a `None` primary URL has no URL to compare, so skip then.
2989            #[cfg(feature = "sqlite")]
2990            if let (Some(primary_url), Some(replica_url)) = (
2991                config.effective_primary_url(),
2992                config.replica_url.as_deref(),
2993            ) {
2994                reject_unusable_sqlite_replica(primary_url, replica_url)?;
2995            }
2996
2997            let replica = config
2998                .replica_url
2999                .as_deref()
3000                .map(|url| {
3001                    build_pool(
3002                        url,
3003                        config.effective_replica_pool_size(),
3004                        config.connect_timeout_secs,
3005                    )
3006                })
3007                .transpose()?;
3008
3009            Ok(Some(DatabaseTopology::from_pools(primary, replica)))
3010        }
3011    }
3012
3013    /// Create one shard's [`DatabaseTopology`] from its
3014    /// `[[database.shards]]` entry.
3015    ///
3016    /// The default implementation uses Autumn's deadpool factory for both
3017    /// roles. Override to decorate per-shard pools (metrics wrappers,
3018    /// circuit breakers) the same way `create_pool` decorates the control
3019    /// role.
3020    ///
3021    /// # Errors
3022    ///
3023    /// Returns [`PoolError`] if either configured role cannot be built.
3024    fn create_shard_topology(
3025        &self,
3026        shard: &crate::config::ShardConfig,
3027        defaults: &DatabaseConfig,
3028    ) -> impl std::future::Future<Output = Result<DatabaseTopology, PoolError>> + Send {
3029        async move { create_shard_topology(shard, defaults) }
3030    }
3031}
3032
3033/// Default [`DatabasePoolProvider`] — the `deadpool + diesel-async` factory.
3034///
3035/// Delegates to the free function [`create_pool`]. This is the provider used
3036/// when no override is installed via
3037/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
3038#[derive(Debug, Default, Clone, Copy)]
3039pub struct DieselDeadpoolPoolProvider;
3040
3041impl DieselDeadpoolPoolProvider {
3042    /// Construct a new default provider.
3043    #[must_use]
3044    pub const fn new() -> Self {
3045        Self
3046    }
3047}
3048
3049impl DatabasePoolProvider for DieselDeadpoolPoolProvider {
3050    async fn create_pool(
3051        &self,
3052        config: &DatabaseConfig,
3053    ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
3054        create_pool(config)
3055    }
3056}
3057
3058#[cfg(test)]
3059mod tests {
3060    use super::*;
3061    use crate::config::DatabaseConfig;
3062    use std::sync::Arc;
3063    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3064    use std::time::Duration;
3065
3066    // ── RequestQueryTimer / Server-Timing db accumulator tests ───
3067
3068    /// The connection instrumentation records one query per completed
3069    /// `StartQuery`/`FinishQuery` pair into the request accumulator, summing
3070    /// elapsed time. Drives the extracted `on_start`/`on_finish` accounting
3071    /// (the `InstrumentationEvent` itself is non-exhaustive and its
3072    /// constructors are gated behind an unstable diesel feature, so it cannot
3073    /// be built in a test). Deterministic: derives finish instants from the
3074    /// start via `Instant + Duration`, no sleeps.
3075    #[cfg(feature = "db")]
3076    #[tokio::test]
3077    async fn request_query_timer_accumulates_finished_queries() {
3078        let timings = Arc::new(RequestDbTimings::default());
3079        REQUEST_DB_TIMINGS
3080            .scope(Arc::clone(&timings), async {
3081                let mut timer = RequestQueryTimer::default();
3082
3083                // Query 1: 1200µs.
3084                let t0 = std::time::Instant::now();
3085                timer.on_start(t0, || "SELECT 1".to_string());
3086                timer.on_finish(t0 + Duration::from_micros(1_200));
3087
3088                // Query 2: 800µs.
3089                let t1 = std::time::Instant::now();
3090                timer.on_start(t1, || "UPDATE users SET name = $1".to_string());
3091                timer.on_finish(t1 + Duration::from_micros(800));
3092
3093                // A stray FinishQuery with no matching StartQuery is ignored.
3094                timer.on_finish(std::time::Instant::now());
3095            })
3096            .await;
3097
3098        assert_eq!(
3099            timings.query_count.load(Ordering::Relaxed),
3100            2,
3101            "only completed StartQuery/FinishQuery pairs are counted"
3102        );
3103        assert_eq!(
3104            timings.total_us.load(Ordering::Relaxed),
3105            2_000,
3106            "elapsed times accumulate (1200µs + 800µs)"
3107        );
3108    }
3109
3110    /// Outside a `REQUEST_DB_TIMINGS` scope the timer records nothing and does
3111    /// not panic — the off-request / middleware-disabled path.
3112    #[cfg(feature = "db")]
3113    #[tokio::test]
3114    async fn request_query_timer_is_noop_off_request() {
3115        let mut timer = RequestQueryTimer::default();
3116        let t0 = std::time::Instant::now();
3117        timer.on_start(t0, || "SELECT 1".to_string());
3118        // Must not panic even though no task-local accumulator is scoped.
3119        timer.on_finish(t0 + Duration::from_micros(500));
3120    }
3121
3122    /// Approach-(b) guarantee: when no `REQUEST_DB_TIMINGS` scope is active,
3123    /// `on_start` must be a cheap no-op — it must not invoke the (allocating)
3124    /// SQL-formatting closure, and must leave no in-flight statement. This is
3125    /// what makes it safe to leave a `RequestQueryTimer` installed on a pooled
3126    /// connection that a later opted-out request reuses.
3127    #[cfg(feature = "db")]
3128    #[tokio::test]
3129    async fn request_query_timer_on_start_is_cheap_noop_off_request() {
3130        let mut timer = RequestQueryTimer::default();
3131        let invoked = std::cell::Cell::new(false);
3132        let t0 = std::time::Instant::now();
3133        timer.on_start(t0, || {
3134            invoked.set(true);
3135            "SELECT 1".to_string()
3136        });
3137        assert!(
3138            !invoked.get(),
3139            "off-request, on_start must not format the SQL (no allocation)"
3140        );
3141        // No in-flight statement was recorded, so on_finish is a no-op.
3142        timer.on_finish(t0 + Duration::from_micros(500));
3143    }
3144
3145    /// Regression for the stale-timer / housekeeping-`SET` bug: a pooled
3146    /// connection reused across requests keeps a `RequestQueryTimer` installed,
3147    /// and `Db::checkout` runs a `SET statement_timeout` before handing the
3148    /// connection over. That housekeeping `SET` must NOT be counted, or every
3149    /// `Server-Timing` request would report a bogus `+1 query` before any app
3150    /// SQL. After the `SET`, the first real `SELECT` increments the count to
3151    /// exactly 1 and only its latency accumulates.
3152    #[cfg(feature = "db")]
3153    #[tokio::test]
3154    async fn request_query_timer_excludes_checkout_statement_timeout_set() {
3155        let timings = Arc::new(RequestDbTimings::default());
3156        REQUEST_DB_TIMINGS
3157            .scope(Arc::clone(&timings), async {
3158                // A timer as it would be freshly installed at checkout.
3159                let mut timer = RequestQueryTimer::default();
3160
3161                // Checkout housekeeping `SET` — must not be counted.
3162                let t0 = std::time::Instant::now();
3163                timer.on_start(t0, || "SET statement_timeout = 5000".to_string());
3164                timer.on_finish(t0 + Duration::from_millis(3));
3165
3166                assert_eq!(
3167                    timings.query_count.load(Ordering::Relaxed),
3168                    0,
3169                    "the checkout SET statement_timeout must not be counted"
3170                );
3171                assert_eq!(
3172                    timings.total_us.load(Ordering::Relaxed),
3173                    0,
3174                    "the checkout SET contributes no latency to db;dur"
3175                );
3176
3177                // First real application query: 600µs.
3178                let t1 = std::time::Instant::now();
3179                timer.on_start(t1, || "SELECT * FROM users".to_string());
3180                timer.on_finish(t1 + Duration::from_micros(600));
3181
3182                assert_eq!(
3183                    timings.query_count.load(Ordering::Relaxed),
3184                    1,
3185                    "the real SELECT is the first counted query"
3186                );
3187                assert_eq!(
3188                    timings.total_us.load(Ordering::Relaxed),
3189                    600,
3190                    "only the SELECT's 600µs accumulates; the SET is excluded"
3191                );
3192            })
3193            .await;
3194    }
3195
3196    /// `request_db_timing_active` — the predicate `RequestQueryTimer::on_start`
3197    /// probes to decide whether to record — must report `false` outside a
3198    /// `REQUEST_DB_TIMINGS` scope (the production / `server_timing` disabled
3199    /// default) and `true` inside one (the `ServerTimingLayer`-scoped request).
3200    /// This predicate gates two things: whether `Db::checkout` installs the
3201    /// timer at all (skipped off-scope so an app's own instrumentation is not
3202    /// clobbered), and whether a stale timer left on a reused connection does
3203    /// any work — with the probe `false`, `on_start` is a cheap no-op.
3204    ///
3205    /// NOTE: the real `Db::checkout` path needs a live Postgres connection,
3206    /// which the sandbox cannot provide, so this exercises the predicate
3207    /// directly.
3208    #[cfg(feature = "db")]
3209    #[tokio::test]
3210    async fn request_db_timing_active_reflects_scope() {
3211        assert!(
3212            !request_db_timing_active(),
3213            "no REQUEST_DB_TIMINGS scope active outside the ServerTimingLayer: \
3214             the timer records nothing"
3215        );
3216
3217        let timings = Arc::new(RequestDbTimings::default());
3218        REQUEST_DB_TIMINGS
3219            .scope(Arc::clone(&timings), async {
3220                assert!(
3221                    request_db_timing_active(),
3222                    "inside a REQUEST_DB_TIMINGS scope the probe is active: \
3223                     the timer records queries"
3224                );
3225            })
3226            .await;
3227
3228        // Scope has ended; the gate reads false again.
3229        assert!(
3230            !request_db_timing_active(),
3231            "the gate is false again once the scope is dropped"
3232        );
3233    }
3234
3235    /// A transaction wraps its inner statements in `BEGIN`/`COMMIT`, which
3236    /// diesel-async runs through `batch_execute` — emitting a
3237    /// `StartQuery`/`FinishQuery` pair with that transaction-control SQL, just
3238    /// like a real query. The timer must skip those so a transaction with one
3239    /// real `SELECT` reports `desc="1 query"`, not `"3 queries"`, and excludes
3240    /// begin/commit latency from `db;dur`.
3241    #[cfg(feature = "db")]
3242    #[tokio::test]
3243    async fn request_query_timer_excludes_transaction_control_statements() {
3244        let timings = Arc::new(RequestDbTimings::default());
3245        REQUEST_DB_TIMINGS
3246            .scope(Arc::clone(&timings), async {
3247                let mut timer = RequestQueryTimer::default();
3248
3249                // BEGIN — must not be counted (10_000µs, would dominate if it
3250                // leaked into the total).
3251                let t0 = std::time::Instant::now();
3252                timer.on_start(t0, || "BEGIN".to_string());
3253                timer.on_finish(t0 + Duration::from_millis(10));
3254
3255                // The single real query: 700µs.
3256                let t1 = std::time::Instant::now();
3257                timer.on_start(t1, || "SELECT * FROM users".to_string());
3258                timer.on_finish(t1 + Duration::from_micros(700));
3259
3260                // COMMIT — must not be counted.
3261                let t2 = std::time::Instant::now();
3262                timer.on_start(t2, || "COMMIT".to_string());
3263                timer.on_finish(t2 + Duration::from_millis(10));
3264            })
3265            .await;
3266
3267        assert_eq!(
3268            timings.query_count.load(Ordering::Relaxed),
3269            1,
3270            "only the real SELECT is counted; BEGIN/COMMIT are excluded"
3271        );
3272        assert_eq!(
3273            timings.total_us.load(Ordering::Relaxed),
3274            700,
3275            "only the SELECT's 700µs accumulates; begin/commit latency is excluded"
3276        );
3277    }
3278
3279    /// The checkout install gate: `Db::checkout` calls `set_instrumentation`
3280    /// (which WHOLESALE REPLACES the connection's instrumentation — clobbering
3281    /// any app-registered `diesel::connection::set_default_instrumentation`)
3282    /// ONLY when `request_db_timing_active()` is true. Outside a
3283    /// `REQUEST_DB_TIMINGS` scope (the `server_timing`-disabled production
3284    /// default) the gate is false, so the guarded install is skipped and a
3285    /// pre-existing sentinel instrumentation is left intact. Inside a scope the
3286    /// timer is installed.
3287    ///
3288    /// NOTE: the real `Db::checkout` path needs a live Postgres connection the
3289    /// sandbox cannot provide, so this models the connection's single
3290    /// instrumentation slot and drives the exact gate expression from
3291    /// `Db::checkout`.
3292    #[cfg(feature = "db")]
3293    #[tokio::test]
3294    async fn checkout_installs_timer_only_when_scope_active() {
3295        // Models the connection's single instrumentation slot. "app" stands for
3296        // an application-registered default (`set_default_instrumentation`);
3297        // "timer" is autumn's `RequestQueryTimer`. Mirrors the guarded block in
3298        // `Db::checkout`: `if request_db_timing_active() { install timer }`.
3299        fn run_checkout_gate(slot: &mut &'static str) {
3300            if request_db_timing_active() {
3301                *slot = "timer";
3302            }
3303        }
3304
3305        // Off-scope (server_timing disabled / production default): the gate is
3306        // false, so the app's instrumentation must survive untouched.
3307        assert!(!request_db_timing_active());
3308        let mut slot = "app";
3309        run_checkout_gate(&mut slot);
3310        assert_eq!(
3311            slot, "app",
3312            "off-scope checkout must NOT clobber the app's instrumentation"
3313        );
3314
3315        // Inside a scope (server_timing enabled, handler wrapped by the layer):
3316        // the gate is true, so autumn installs its RequestQueryTimer.
3317        let timings = Arc::new(RequestDbTimings::default());
3318        REQUEST_DB_TIMINGS
3319            .scope(Arc::clone(&timings), async {
3320                let mut slot = "app";
3321                run_checkout_gate(&mut slot);
3322                assert_eq!(
3323                    slot, "timer",
3324                    "inside a REQUEST_DB_TIMINGS scope autumn installs its timer"
3325                );
3326            })
3327            .await;
3328    }
3329
3330    /// Unit coverage for the uncounted-statement classifier used by the timer:
3331    /// leading-token match is case-insensitive and covers savepoint/release, the
3332    /// two-word `START TRANSACTION` form, and any leading `SET` (both
3333    /// `SET TRANSACTION` and the checkout `SET statement_timeout` housekeeping),
3334    /// while real application queries are counted. `UPDATE ... SET ...` is
3335    /// counted because `SET` is not its leading token.
3336    #[cfg(feature = "db")]
3337    #[test]
3338    fn is_uncounted_statement_classifies_statements() {
3339        for sql in [
3340            "BEGIN",
3341            "begin",
3342            "  COMMIT",
3343            "ROLLBACK",
3344            "ROLLBACK TO SAVEPOINT diesel_savepoint_0",
3345            "SAVEPOINT diesel_savepoint_1",
3346            "RELEASE SAVEPOINT diesel_savepoint_0",
3347            "start transaction",
3348            "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE",
3349            "SET statement_timeout = 5000",
3350            "set statement_timeout = 0",
3351        ] {
3352            assert!(
3353                RequestQueryTimer::is_uncounted_statement(sql),
3354                "{sql:?} should be treated as an uncounted housekeeping/tx statement"
3355            );
3356        }
3357
3358        for sql in [
3359            "SELECT 1",
3360            "UPDATE users SET name = $1",
3361            "INSERT INTO t VALUES (1)",
3362            "DELETE FROM t WHERE id = 1",
3363            "",
3364        ] {
3365            assert!(
3366                !RequestQueryTimer::is_uncounted_statement(sql),
3367                "{sql:?} should be treated as a countable query"
3368            );
3369        }
3370    }
3371
3372    /// Finding-1 regression: the capture path stores the parameterised
3373    /// statement WITHOUT diesel's trailing `-- binds: [...]` annotation, so the
3374    /// per-row executions of an N+1 pattern (which differ only in their bind
3375    /// values) collapse to a single template and `detect_n_plus_one` catches
3376    /// them. Without the strip, each `-- binds: [N]` would normalise to a
3377    /// distinct template and the N+1 would go undetected. Locks the fix with no
3378    /// live database.
3379    #[cfg(feature = "db")]
3380    #[test]
3381    fn strip_bind_annotation_lets_detect_n_plus_one_see_per_row_repetition() {
3382        use crate::inspector::{QueryRecord, detect_n_plus_one};
3383
3384        // Construct QueryRecords exactly as `record_request_db_query` does:
3385        // the captured `sql` is diesel's `DebugQuery` `Display` output run
3386        // through `strip_bind_annotation`.
3387        fn record(diesel_display: &str) -> QueryRecord {
3388            QueryRecord {
3389                sql: strip_bind_annotation(diesel_display).to_owned(),
3390                params: Vec::new(),
3391                elapsed_ms: 1,
3392                location: String::new(),
3393            }
3394        }
3395
3396        // The `-- binds:` marker (and the leading whitespace before it) is
3397        // stripped; the `$N` placeholder is preserved.
3398        assert_eq!(
3399            strip_bind_annotation("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
3400            "SELECT * FROM books WHERE author_id = $1"
3401        );
3402        // Zero-bind annotation is stripped too.
3403        assert_eq!(
3404            strip_bind_annotation("SELECT * FROM authors -- binds: []"),
3405            "SELECT * FROM authors"
3406        );
3407        // No marker (transaction-control / synthetic input) → unchanged.
3408        assert_eq!(strip_bind_annotation("SELECT 1"), "SELECT 1");
3409        assert_eq!(strip_bind_annotation("BEGIN"), "BEGIN");
3410
3411        // A classic N+1: one parent query, then the same per-row child query
3412        // executed once per row with a different bind value each time.
3413        let n_plus_one = vec![
3414            record("SELECT * FROM authors -- binds: []"),
3415            record("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
3416            record("SELECT * FROM books WHERE author_id = $1 -- binds: [2]"),
3417            record("SELECT * FROM books WHERE author_id = $1 -- binds: [3]"),
3418        ];
3419        let warning = detect_n_plus_one(&n_plus_one, 3)
3420            .expect("three identical per-row templates should trip the N+1 detector");
3421        assert_eq!(
3422            warning.count, 3,
3423            "all three per-row executions collapse to a single template"
3424        );
3425        assert_eq!(
3426            warning.sql_template, "select * from books where author_id = $1",
3427            "the reported template is the parameterised statement, bind-free"
3428        );
3429
3430        // Distinct query templates must NOT trip the detector, even at the
3431        // same total query count.
3432        let distinct = vec![
3433            record("SELECT * FROM authors -- binds: []"),
3434            record("SELECT * FROM books WHERE id = $1 -- binds: [1]"),
3435            record("UPDATE users SET name = $1 WHERE id = $2 -- binds: [\"x\", 2]"),
3436        ];
3437        assert!(
3438            detect_n_plus_one(&distinct, 3).is_none(),
3439            "three distinct query templates must not be reported as N+1"
3440        );
3441    }
3442
3443    // ── after_commit tests ───────────────────────────────────────
3444
3445    #[tokio::test]
3446    async fn register_after_commit_outside_tx_runs_eagerly() {
3447        // When called outside a db.tx block, the callback should run immediately.
3448        let counter = Arc::new(AtomicUsize::new(0));
3449        let c = counter.clone();
3450        register_after_commit(move || async move {
3451            c.fetch_add(1, Ordering::SeqCst);
3452            Ok(())
3453        })
3454        .await;
3455        assert_eq!(counter.load(Ordering::SeqCst), 1);
3456    }
3457
3458    #[tokio::test]
3459    async fn register_after_commit_eager_failure_increments_failure_counter() {
3460        let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3461
3462        register_after_commit(|| async {
3463            Err(crate::AutumnError::internal_server_error_msg(
3464                "deliberate eager after-commit failure",
3465            ))
3466        })
3467        .await;
3468
3469        let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3470        assert!(
3471            after > before,
3472            "eager after_commit failures should be counted for recovery signals"
3473        );
3474    }
3475
3476    #[tokio::test]
3477    async fn register_after_commit_inside_scope_defers_until_drained() {
3478        // Inside a task-local scope (simulating Db::tx), callbacks are deferred.
3479        let counter = Arc::new(AtomicUsize::new(0));
3480        let c = counter.clone();
3481
3482        let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3483
3484        // Simulate being inside a db.tx by setting the task-local
3485        AFTER_COMMIT_REGISTRY
3486            .scope(registry.clone(), async {
3487                register_after_commit(move || async move {
3488                    c.fetch_add(1, Ordering::SeqCst);
3489                    Ok(())
3490                })
3491                .await;
3492            })
3493            .await;
3494
3495        // Callback must NOT have run yet
3496        assert_eq!(counter.load(Ordering::SeqCst), 0);
3497
3498        // Drain and run the callbacks (simulating post-commit)
3499        let callbacks: Vec<CommitCallback> = {
3500            let mut reg = registry.lock().unwrap();
3501            std::mem::take(&mut *reg)
3502        };
3503        for cb in callbacks {
3504            cb().await.unwrap();
3505        }
3506
3507        assert_eq!(counter.load(Ordering::SeqCst), 1);
3508    }
3509
3510    #[tokio::test]
3511    async fn register_after_commit_on_rollback_callbacks_dropped() {
3512        // Callbacks registered inside a tx scope that is NOT drained are dropped.
3513        let counter = Arc::new(AtomicUsize::new(0));
3514        let c = counter.clone();
3515
3516        let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3517
3518        AFTER_COMMIT_REGISTRY
3519            .scope(registry.clone(), async {
3520                register_after_commit(move || async move {
3521                    c.fetch_add(1, Ordering::SeqCst);
3522                    Ok(())
3523                })
3524                .await;
3525            })
3526            .await;
3527
3528        // Simulate rollback: drop the callbacks without running them
3529        drop(registry);
3530
3531        assert_eq!(counter.load(Ordering::SeqCst), 0);
3532    }
3533
3534    #[tokio::test]
3535    async fn register_after_commit_callbacks_run_in_registration_order() {
3536        let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
3537        let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3538
3539        let o1 = order.clone();
3540        let o2 = order.clone();
3541        let o3 = order.clone();
3542
3543        AFTER_COMMIT_REGISTRY
3544            .scope(registry.clone(), async {
3545                register_after_commit(move || async move {
3546                    o1.lock().unwrap().push(1);
3547                    Ok(())
3548                })
3549                .await;
3550                register_after_commit(move || async move {
3551                    o2.lock().unwrap().push(2);
3552                    Ok(())
3553                })
3554                .await;
3555                register_after_commit(move || async move {
3556                    o3.lock().unwrap().push(3);
3557                    Ok(())
3558                })
3559                .await;
3560            })
3561            .await;
3562
3563        let callbacks: Vec<CommitCallback> = {
3564            let mut reg = registry.lock().unwrap();
3565            std::mem::take(&mut *reg)
3566        };
3567        for cb in callbacks {
3568            cb().await.unwrap();
3569        }
3570
3571        assert_eq!(*order.lock().unwrap(), vec![1, 2, 3]);
3572    }
3573
3574    #[tokio::test]
3575    async fn production_after_commit_drain_preserves_registration_order() {
3576        let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
3577        let (release_first, wait_first) = tokio::sync::oneshot::channel::<()>();
3578
3579        let first_order = order.clone();
3580        let second_order = order.clone();
3581        let callbacks: Vec<CommitCallback> = vec![
3582            Box::new(move || {
3583                Box::pin(async move {
3584                    wait_first
3585                        .await
3586                        .expect("test should release first callback");
3587                    first_order.lock().unwrap().push(1);
3588                    Ok(())
3589                })
3590            }),
3591            Box::new(move || {
3592                Box::pin(async move {
3593                    second_order.lock().unwrap().push(2);
3594                    Ok(())
3595                })
3596            }),
3597        ];
3598
3599        let drain = spawn_committed_after_commit_callbacks(callbacks)
3600            .expect("non-empty callback list should spawn a drain task");
3601        tokio::task::yield_now().await;
3602
3603        assert_eq!(
3604            *order.lock().unwrap(),
3605            Vec::<u32>::new(),
3606            "later callbacks must wait for earlier callbacks to finish"
3607        );
3608
3609        release_first
3610            .send(())
3611            .expect("first callback receiver alive");
3612        drain.await.expect("drain task should not panic");
3613
3614        assert_eq!(*order.lock().unwrap(), vec![1, 2]);
3615    }
3616
3617    #[tokio::test]
3618    async fn production_after_commit_drain_isolates_panicking_callbacks() {
3619        let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3620        let ran_later = Arc::new(AtomicU64::new(0));
3621        let later = ran_later.clone();
3622
3623        let callbacks: Vec<CommitCallback> = vec![
3624            Box::new(|| Box::pin(async { panic!("deliberate after_commit panic") })),
3625            Box::new(move || {
3626                Box::pin(async move {
3627                    later.fetch_add(1, Ordering::SeqCst);
3628                    Ok(())
3629                })
3630            }),
3631        ];
3632
3633        let drain = spawn_committed_after_commit_callbacks(callbacks)
3634            .expect("non-empty callback list should spawn a drain task");
3635        drain.await.expect("panicking callback should be isolated");
3636
3637        assert_eq!(
3638            ran_later.load(Ordering::SeqCst),
3639            1,
3640            "later callbacks must still run after an earlier callback panics"
3641        );
3642        let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3643        assert!(
3644            after > before,
3645            "panicking after_commit callbacks must increment the failure counter"
3646        );
3647    }
3648
3649    #[tokio::test]
3650    async fn db_tx_rejects_ambient_after_commit_registry() {
3651        let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3652
3653        let err = AFTER_COMMIT_REGISTRY
3654            .scope(registry, async {
3655                reject_ambient_after_commit_registry_for_tx().expect_err(
3656                    "starting Db::tx inside an ambient transaction registry should fail",
3657                )
3658            })
3659            .await;
3660
3661        assert!(
3662            err.to_string().contains("Nested Db::tx calls"),
3663            "unexpected nested transaction error: {err}"
3664        );
3665    }
3666
3667    #[tokio::test]
3668    async fn register_after_commit_callback_error_is_swallowed() {
3669        // A failing callback is logged but doesn't panic or propagate.
3670        let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3671
3672        AFTER_COMMIT_REGISTRY
3673            .scope(registry.clone(), async {
3674                register_after_commit(|| async {
3675                    Err(crate::AutumnError::internal_server_error_msg(
3676                        "deliberate error",
3677                    ))
3678                })
3679                .await;
3680            })
3681            .await;
3682
3683        let callbacks: Vec<CommitCallback> = {
3684            let mut reg = registry.lock().unwrap();
3685            std::mem::take(&mut *reg)
3686        };
3687        // Running a failing callback should not panic
3688        for cb in callbacks {
3689            let _ = cb().await;
3690        }
3691    }
3692
3693    // ── Pool provider trait tests ────────────────────────────────
3694
3695    /// No-op provider for tests — always returns `Ok(None)` regardless of the
3696    /// supplied config. Verifies the trait actually overrides the default
3697    /// (which would otherwise build a pool from the URL).
3698    struct NoOpPoolProvider;
3699
3700    impl DatabasePoolProvider for NoOpPoolProvider {
3701        async fn create_pool(
3702            &self,
3703            _config: &DatabaseConfig,
3704        ) -> Result<Option<Pool<crate::db::RuntimeConnection>>, PoolError> {
3705            Ok(None)
3706        }
3707    }
3708
3709    #[tokio::test]
3710    async fn pool_provider_trait_returns_supplied_pool() {
3711        // Even with a configured URL, the no-op provider returns None — proving
3712        // the trait can replace the default factory's behaviour.
3713        let config = DatabaseConfig {
3714            url: Some("postgres://localhost/ignored".to_owned()),
3715            ..Default::default()
3716        };
3717        let provider = NoOpPoolProvider;
3718        let pool = provider
3719            .create_pool(&config)
3720            .await
3721            .expect("no-op provider should succeed");
3722        assert!(
3723            pool.is_none(),
3724            "no-op provider must override default behaviour"
3725        );
3726    }
3727
3728    #[tokio::test]
3729    async fn default_pool_provider_matches_free_function() {
3730        let config = DatabaseConfig::default();
3731        let via_provider = DieselDeadpoolPoolProvider::new()
3732            .create_pool(&config)
3733            .await
3734            .expect("default provider should succeed");
3735        let via_function = create_pool(&config).expect("free fn should succeed");
3736        assert_eq!(via_provider.is_none(), via_function.is_none());
3737    }
3738
3739    // ── Pool creation tests ──────────────────────────────────────
3740
3741    #[tokio::test]
3742    async fn default_pool_provider_respects_url_config() {
3743        let config = DatabaseConfig {
3744            url: Some("postgres://localhost/test".into()),
3745            ..Default::default()
3746        };
3747        let provider = DieselDeadpoolPoolProvider::new();
3748        let pool = provider
3749            .create_pool(&config)
3750            .await
3751            .expect("default provider should succeed");
3752        assert!(
3753            pool.is_some(),
3754            "default provider should return Some when url is provided"
3755        );
3756    }
3757
3758    #[test]
3759    fn create_pool_with_no_url_returns_none() {
3760        let config = DatabaseConfig::default();
3761        let pool = create_pool(&config).expect("should not fail with no URL");
3762        assert!(pool.is_none());
3763    }
3764
3765    #[test]
3766    fn create_pool_with_url_returns_some() {
3767        let config = DatabaseConfig {
3768            url: Some("postgres://localhost/test".into()),
3769            ..Default::default()
3770        };
3771        let pool = create_pool(&config).expect("should build pool from valid config");
3772        assert!(pool.is_some());
3773    }
3774
3775    // In the default (Postgres) build a SQLite target has no runtime pool, so
3776    // pool construction must refuse at boot with an actionable message pointing
3777    // at the `--features sqlite` build — never reaching a first-query failure or
3778    // panic. Under `--features sqlite` the same target instead builds a real
3779    // pool (see the `sqlite_boot_serve` integration test), so this refusal
3780    // contract only applies to the default build.
3781    #[cfg(not(feature = "sqlite"))]
3782    #[test]
3783    fn create_pool_with_sqlite_url_fails_fast() {
3784        let config = DatabaseConfig {
3785            url: Some("sqlite:///var/lib/app.db".into()),
3786            ..Default::default()
3787        };
3788        // The Ok variant (`Option<Pool>`) is not `Debug`, so match rather than
3789        // use `expect_err`.
3790        let Err(err) = create_pool(&config) else {
3791            panic!("sqlite target must refuse at pool build");
3792        };
3793        assert!(
3794            matches!(err, PoolError::UnsupportedBackend(_)),
3795            "expected UnsupportedBackend, got: {err:?}"
3796        );
3797        let msg = err.to_string();
3798        assert!(
3799            msg.contains("SQLite") && msg.contains("--features sqlite"),
3800            "message must be actionable and name the sqlite build, got: {msg}"
3801        );
3802    }
3803
3804    #[cfg(not(feature = "sqlite"))]
3805    #[test]
3806    fn create_topology_with_sqlite_url_fails_fast() {
3807        let config = DatabaseConfig {
3808            primary_url: Some("sqlite::memory:".into()),
3809            ..Default::default()
3810        };
3811        let Err(err) = create_topology(&config) else {
3812            panic!("sqlite target must refuse at topology build");
3813        };
3814        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3815    }
3816
3817    // Under `--features sqlite` the same targets that refuse above instead build
3818    // a real pool/topology over `SyncConnectionWrapper<SqliteConnection>`.
3819    #[cfg(feature = "sqlite")]
3820    #[test]
3821    fn create_pool_with_sqlite_url_builds_under_feature() {
3822        let config = DatabaseConfig {
3823            primary_url: Some("sqlite::memory:".into()),
3824            ..Default::default()
3825        };
3826        let pool = create_pool(&config).expect("sqlite pool builds under the feature");
3827        assert!(pool.is_some(), "a configured sqlite url yields a pool");
3828    }
3829
3830    // A Postgres URL in a sqlite build is a misconfiguration and must refuse.
3831    #[cfg(feature = "sqlite")]
3832    #[test]
3833    fn create_pool_with_postgres_url_refuses_under_sqlite_feature() {
3834        let config = DatabaseConfig {
3835            url: Some("postgres://localhost/test".into()),
3836            ..Default::default()
3837        };
3838        let Err(err) = create_pool(&config) else {
3839            panic!("a Postgres url must refuse under the sqlite feature");
3840        };
3841        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3842    }
3843
3844    // A SQLite runtime has no primary/replica replication in this pool
3845    // architecture, so a separate replica pool would serve reads from an empty
3846    // database. `create_topology` must reject an in-memory or distinct-file
3847    // replica with an actionable boot error (addresses Codex P2).
3848    #[cfg(feature = "sqlite")]
3849    #[test]
3850    fn create_topology_rejects_in_memory_sqlite_replica() {
3851        let config = DatabaseConfig {
3852            primary_url: Some("sqlite:///var/lib/app.db".into()),
3853            replica_url: Some("sqlite::memory:".into()),
3854            ..Default::default()
3855        };
3856        let Err(err) = create_topology(&config) else {
3857            panic!("an in-memory sqlite replica must be rejected");
3858        };
3859        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3860        let msg = err.to_string();
3861        assert!(
3862            msg.contains("separate read replica") && msg.contains("primary"),
3863            "message must be actionable: {msg}"
3864        );
3865    }
3866
3867    #[cfg(feature = "sqlite")]
3868    #[test]
3869    fn create_topology_rejects_distinct_file_sqlite_replica() {
3870        let config = DatabaseConfig {
3871            primary_url: Some("sqlite:///var/lib/primary.db".into()),
3872            replica_url: Some("sqlite:///var/lib/replica.db".into()),
3873            ..Default::default()
3874        };
3875        let Err(err) = create_topology(&config) else {
3876            panic!("a distinct-file sqlite replica must be rejected");
3877        };
3878        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3879    }
3880
3881    // A replica that normalizes to the SAME file as the primary is the same
3882    // database — harmless — so it is allowed and the topology builds.
3883    #[cfg(feature = "sqlite")]
3884    #[test]
3885    fn create_topology_allows_same_file_sqlite_replica() {
3886        let config = DatabaseConfig {
3887            primary_url: Some("sqlite:///var/lib/app.db".into()),
3888            replica_url: Some("sqlite:///var/lib/app.db".into()),
3889            ..Default::default()
3890        };
3891        let topology = create_topology(&config)
3892            .expect("same-file sqlite replica must be allowed")
3893            .expect("a configured primary yields a topology");
3894        assert!(
3895            topology.replica().is_some(),
3896            "same-file replica pool should be built"
3897        );
3898    }
3899
3900    // The no-replica happy path still builds a topology fine under the feature.
3901    #[cfg(feature = "sqlite")]
3902    #[test]
3903    fn create_topology_sqlite_primary_only_builds() {
3904        let config = DatabaseConfig {
3905            primary_url: Some("sqlite::memory:".into()),
3906            ..Default::default()
3907        };
3908        let topology = create_topology(&config)
3909            .expect("sqlite primary-only topology builds")
3910            .expect("a configured primary yields a topology");
3911        assert!(topology.replica().is_none(), "no replica configured");
3912    }
3913
3914    // A custom provider that overrides ONLY `create_pool` still has its replica
3915    // built by the trait DEFAULT `create_topology`. That default path must apply
3916    // the same SQLite-replica rule as the free `create_topology` /
3917    // `create_shard_topology`, or a provider could boot a distinct/in-memory
3918    // replica and route reads to an unrelated, empty database (addresses Codex
3919    // P2). This minimal provider delegates `create_pool` to the default factory
3920    // and leaves `create_topology` as the trait default — exactly the path under
3921    // test.
3922    #[cfg(feature = "sqlite")]
3923    struct PrimaryOnlyProvider;
3924
3925    #[cfg(feature = "sqlite")]
3926    impl DatabasePoolProvider for PrimaryOnlyProvider {
3927        async fn create_pool(
3928            &self,
3929            config: &DatabaseConfig,
3930        ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
3931            create_pool(config)
3932        }
3933    }
3934
3935    #[cfg(feature = "sqlite")]
3936    #[tokio::test]
3937    async fn default_provider_topology_rejects_distinct_file_sqlite_replica() {
3938        let config = DatabaseConfig {
3939            primary_url: Some("sqlite:///var/lib/primary.db".into()),
3940            replica_url: Some("sqlite:///var/lib/replica.db".into()),
3941            ..Default::default()
3942        };
3943        let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
3944            panic!("the default-topology path must reject a distinct-file sqlite replica");
3945        };
3946        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3947        let msg = err.to_string();
3948        assert!(
3949            msg.contains("separate read replica") && msg.contains("primary"),
3950            "message must be actionable: {msg}"
3951        );
3952    }
3953
3954    #[cfg(feature = "sqlite")]
3955    #[tokio::test]
3956    async fn default_provider_topology_rejects_in_memory_sqlite_replica() {
3957        let config = DatabaseConfig {
3958            primary_url: Some("sqlite:///var/lib/app.db".into()),
3959            replica_url: Some("sqlite::memory:".into()),
3960            ..Default::default()
3961        };
3962        let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
3963            panic!("an in-memory sqlite replica must be rejected by the default topology");
3964        };
3965        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3966    }
3967
3968    // A same-file replica is the same database (harmless) and still builds
3969    // through the default topology; a primary-only config builds with no replica.
3970    #[cfg(feature = "sqlite")]
3971    #[tokio::test]
3972    async fn default_provider_topology_allows_same_file_and_primary_only() {
3973        let same_file = DatabaseConfig {
3974            primary_url: Some("sqlite:///var/lib/app.db".into()),
3975            replica_url: Some("sqlite:///var/lib/app.db".into()),
3976            ..Default::default()
3977        };
3978        let topology = PrimaryOnlyProvider
3979            .create_topology(&same_file)
3980            .await
3981            .expect("same-file replica must be allowed by the default topology")
3982            .expect("a configured primary yields a topology");
3983        assert!(
3984            topology.replica().is_some(),
3985            "same-file replica pool should be built"
3986        );
3987
3988        let primary_only = DatabaseConfig {
3989            primary_url: Some("sqlite::memory:".into()),
3990            ..Default::default()
3991        };
3992        let topology = PrimaryOnlyProvider
3993            .create_topology(&primary_only)
3994            .await
3995            .expect("primary-only default topology builds")
3996            .expect("a configured primary yields a topology");
3997        assert!(topology.replica().is_none(), "no replica configured");
3998    }
3999
4000    // The per-shard replica is subject to the SAME SQLite-replica rule as the
4001    // control database (addresses Codex P2): an in-memory or distinct-file shard
4002    // replica would route reads to an unrelated, empty database, so it must be
4003    // rejected; a shard with only a primary (or a same-file replica) must build.
4004    #[cfg(feature = "sqlite")]
4005    #[test]
4006    fn create_shard_topology_rejects_in_memory_sqlite_replica() {
4007        let defaults = DatabaseConfig::default();
4008        let shard = crate::config::ShardConfig {
4009            name: "shard0".into(),
4010            primary_url: "sqlite:///var/lib/shard0.db".into(),
4011            replica_url: Some("sqlite::memory:".into()),
4012            ..Default::default()
4013        };
4014        let Err(err) = create_shard_topology(&shard, &defaults) else {
4015            panic!("an in-memory sqlite shard replica must be rejected");
4016        };
4017        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
4018        let msg = err.to_string();
4019        assert!(
4020            msg.contains("separate read replica") && msg.contains("primary"),
4021            "message must be actionable: {msg}"
4022        );
4023    }
4024
4025    #[cfg(feature = "sqlite")]
4026    #[test]
4027    fn create_shard_topology_rejects_distinct_file_sqlite_replica() {
4028        let defaults = DatabaseConfig::default();
4029        let shard = crate::config::ShardConfig {
4030            name: "shard0".into(),
4031            primary_url: "sqlite:///var/lib/shard0-primary.db".into(),
4032            replica_url: Some("sqlite:///var/lib/shard0-replica.db".into()),
4033            ..Default::default()
4034        };
4035        let Err(err) = create_shard_topology(&shard, &defaults) else {
4036            panic!("a distinct-file sqlite shard replica must be rejected");
4037        };
4038        assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
4039    }
4040
4041    #[cfg(feature = "sqlite")]
4042    #[test]
4043    fn create_shard_topology_allows_same_file_sqlite_replica() {
4044        let defaults = DatabaseConfig::default();
4045        let shard = crate::config::ShardConfig {
4046            name: "shard0".into(),
4047            primary_url: "sqlite:///var/lib/shard0.db".into(),
4048            replica_url: Some("sqlite:///var/lib/shard0.db".into()),
4049            ..Default::default()
4050        };
4051        let topology = create_shard_topology(&shard, &defaults)
4052            .expect("same-file sqlite shard replica must be allowed");
4053        assert!(
4054            topology.replica().is_some(),
4055            "same-file shard replica pool should be built"
4056        );
4057    }
4058
4059    #[cfg(feature = "sqlite")]
4060    #[test]
4061    fn create_shard_topology_sqlite_primary_only_builds() {
4062        let defaults = DatabaseConfig::default();
4063        let shard = crate::config::ShardConfig {
4064            name: "shard0".into(),
4065            primary_url: "sqlite::memory:".into(),
4066            ..Default::default()
4067        };
4068        let topology = create_shard_topology(&shard, &defaults)
4069            .expect("sqlite primary-only shard topology builds");
4070        assert!(topology.replica().is_none(), "no shard replica configured");
4071    }
4072
4073    // `file::memory:` (and its query-string form) is a private in-memory target
4074    // and must be forced single-slot; a shared-cache in-memory DB is shareable
4075    // and must NOT be (addresses Codex P1).
4076    #[cfg(feature = "sqlite")]
4077    #[test]
4078    fn sqlite_target_is_memory_covers_file_memory() {
4079        assert!(sqlite_target_is_memory(":memory:"));
4080        assert!(sqlite_target_is_memory("file::memory:"));
4081        assert!(sqlite_target_is_memory("file::memory:?foo=bar"));
4082        assert!(sqlite_target_is_memory("file:app?mode=memory"));
4083        // Shared-cache in-memory databases are shareable across connections.
4084        assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
4085        assert!(!sqlite_target_is_memory(
4086            "file:app?mode=memory&cache=shared"
4087        ));
4088        // Plain file targets are not in-memory.
4089        assert!(!sqlite_target_is_memory("/var/lib/app.db"));
4090    }
4091
4092    // `sqlite_target_is_any_in_memory` is the broader predicate the
4093    // startup-migration reject uses: it classifies EVERY in-memory spelling —
4094    // private AND shared-cache — as in-memory, because none of them survive the
4095    // transient migration connection closing to reach the runtime pool (issue
4096    // #1614 follow-up). It must diverge from `sqlite_target_is_memory` (pool
4097    // sizing) precisely on `cache=shared`, which sizing keeps NOT-in-memory so a
4098    // shared-cache DB is never forced single-slot.
4099    #[cfg(feature = "sqlite")]
4100    #[test]
4101    fn sqlite_target_is_any_in_memory_covers_shared_cache() {
4102        // Private in-memory spellings — in-memory for BOTH predicates.
4103        assert!(sqlite_target_is_any_in_memory(":memory:"));
4104        assert!(sqlite_target_is_any_in_memory("sqlite::memory:"));
4105        assert!(sqlite_target_is_any_in_memory("sqlite://:memory:"));
4106        assert!(sqlite_target_is_any_in_memory("sqlite://"));
4107        assert!(sqlite_target_is_any_in_memory("file::memory:"));
4108        assert!(sqlite_target_is_any_in_memory("file::memory:?foo=bar"));
4109        // Shared-cache in-memory: `any_in_memory` → true (rejected for
4110        // migrations), but pool sizing's `sqlite_target_is_memory` → false (NOT
4111        // forced single-slot). This divergence is the whole point.
4112        assert!(sqlite_target_is_any_in_memory("file::memory:?cache=shared"));
4113        assert!(sqlite_target_is_any_in_memory(
4114            "file:app?mode=memory&cache=shared"
4115        ));
4116        assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
4117        assert!(!sqlite_target_is_memory(
4118            "file:app?mode=memory&cache=shared"
4119        ));
4120        // File-backed targets are never in-memory under either predicate.
4121        assert!(!sqlite_target_is_any_in_memory("sqlite:///var/lib/app.db"));
4122        assert!(!sqlite_target_is_any_in_memory("/var/lib/app.db"));
4123        assert!(!sqlite_target_is_any_in_memory("file:/var/lib/app.db"));
4124    }
4125
4126    // The transient SQLite migration connection must carry `PRAGMA busy_timeout`
4127    // (mirroring the runtime pool's per-connection setup) so a concurrent
4128    // migrator or a briefly-held write lock WAITS instead of failing immediately
4129    // with SQLITE_BUSY and aborting `auto_migrate_sqlite` (Codex P1). Proof: a
4130    // fresh migration connection reports the configured non-zero timeout.
4131    #[cfg(feature = "sqlite")]
4132    #[test]
4133    fn sqlite_migration_connection_sets_busy_timeout() {
4134        use diesel::RunQueryDsl as _;
4135
4136        #[derive(diesel::QueryableByName)]
4137        struct BusyTimeout {
4138            #[diesel(sql_type = diesel::sql_types::Integer)]
4139            timeout: i32,
4140        }
4141
4142        // A tempfile-backed target (not `:memory:`) so the connection maps to a
4143        // real database the pragma read reflects.
4144        let tmp = tempfile::TempDir::new().expect("temp dir");
4145        let db_path = tmp.path().join("migration.db");
4146        let url = format!("sqlite://{}", db_path.display());
4147
4148        let mut conn = super::establish_sqlite_migration_connection(&url)
4149            .expect("establish sqlite migration connection");
4150        let rows: Vec<BusyTimeout> = diesel::sql_query("PRAGMA busy_timeout")
4151            .load(&mut conn)
4152            .expect("read busy_timeout pragma");
4153        let timeout = rows
4154            .into_iter()
4155            .next()
4156            .expect("busy_timeout pragma returns a row")
4157            .timeout;
4158        assert_eq!(
4159            timeout, 5000,
4160            "the migration connection must carry the configured busy_timeout (ms) \
4161             so concurrent/locked migrators wait rather than hitting SQLITE_BUSY"
4162        );
4163    }
4164
4165    // A read-only URI target (`mode=ro` / `immutable`) must be detected so the
4166    // per-connection setup batch skips the write-affecting `journal_mode = WAL`
4167    // pragma, which otherwise fails with "attempt to write a readonly database"
4168    // and 503s the whole pool (Codex P2).
4169    #[cfg(feature = "sqlite")]
4170    #[test]
4171    fn sqlite_target_is_read_only_detects_ro_and_immutable() {
4172        assert!(sqlite_target_is_read_only("file:/srv/ref.db?mode=ro"));
4173        // Case-insensitive key/value.
4174        assert!(sqlite_target_is_read_only("file:/srv/ref.db?MODE=RO"));
4175        assert!(sqlite_target_is_read_only("file:/srv/ref.db?immutable=1"));
4176        assert!(sqlite_target_is_read_only(
4177            "file:/srv/ref.db?immutable=true"
4178        ));
4179        assert!(sqlite_target_is_read_only(
4180            "file:/srv/ref.db?immutable=TRUE"
4181        ));
4182        // Read-only marker alongside other params.
4183        assert!(sqlite_target_is_read_only(
4184            "file:/srv/ref.db?cache=shared&mode=ro"
4185        ));
4186
4187        // A plain file, an in-memory target, and a writable/shared-cache target
4188        // are NOT read-only.
4189        assert!(!sqlite_target_is_read_only("/var/lib/app.db"));
4190        assert!(!sqlite_target_is_read_only(":memory:"));
4191        assert!(!sqlite_target_is_read_only("file::memory:?cache=shared"));
4192        assert!(!sqlite_target_is_read_only("file:app?mode=memory"));
4193        assert!(!sqlite_target_is_read_only("file:/srv/ref.db?mode=rwc"));
4194        assert!(!sqlite_target_is_read_only("file:/srv/ref.db?immutable=0"));
4195        // A value that merely CONTAINS `ro` must not trip the substring trap.
4196        assert!(!sqlite_target_is_read_only("file:/srv/ro-data.db"));
4197    }
4198
4199    #[cfg(feature = "sqlite")]
4200    #[test]
4201    fn file_memory_sqlite_pool_is_single_slot() {
4202        let config = DatabaseConfig {
4203            primary_url: Some("file::memory:".into()),
4204            pool_size: 5,
4205            ..Default::default()
4206        };
4207        let pool = create_pool(&config)
4208            .expect("file::memory: sqlite pool builds")
4209            .expect("a configured url yields a pool");
4210        assert_eq!(
4211            pool.status().max_size,
4212            1,
4213            "a private in-memory target must be forced single-slot"
4214        );
4215    }
4216
4217    #[cfg(feature = "sqlite")]
4218    #[test]
4219    fn shared_cache_in_memory_sqlite_pool_is_not_forced_single_slot() {
4220        let config = DatabaseConfig {
4221            primary_url: Some("file::memory:?cache=shared".into()),
4222            pool_size: 5,
4223            ..Default::default()
4224        };
4225        let pool = create_pool(&config)
4226            .expect("shared-cache in-memory sqlite pool builds")
4227            .expect("a configured url yields a pool");
4228        assert_eq!(
4229            pool.status().max_size,
4230            5,
4231            "a shared-cache in-memory target must respect the configured size"
4232        );
4233    }
4234
4235    #[cfg(feature = "sqlite")]
4236    #[test]
4237    fn normalize_sqlite_target_strips_schemes() {
4238        assert_eq!(normalize_sqlite_target("sqlite::memory:"), ":memory:");
4239        assert_eq!(normalize_sqlite_target("sqlite://:memory:"), ":memory:");
4240        assert_eq!(normalize_sqlite_target("sqlite://"), ":memory:");
4241        assert_eq!(
4242            normalize_sqlite_target("sqlite:///var/lib/app.db"),
4243            "/var/lib/app.db"
4244        );
4245        assert_eq!(normalize_sqlite_target("sqlite:app.db"), "app.db");
4246        assert_eq!(
4247            normalize_sqlite_target("file:/var/lib/app.db"),
4248            "file:/var/lib/app.db"
4249        );
4250    }
4251
4252    #[test]
4253    fn pool_respects_max_size() {
4254        let config = DatabaseConfig {
4255            url: Some("postgres://localhost/test".into()),
4256            pool_size: 5,
4257            ..Default::default()
4258        };
4259        let pool = create_pool(&config)
4260            .expect("should build pool")
4261            .expect("should be Some");
4262        assert_eq!(pool.status().max_size, 5);
4263    }
4264
4265    #[test]
4266    fn pool_clamps_size_to_one_if_zero() {
4267        let config = DatabaseConfig {
4268            url: Some("postgres://localhost/test".into()),
4269            pool_size: 0,
4270            ..Default::default()
4271        };
4272        let pool = create_pool(&config)
4273            .expect("should build pool")
4274            .expect("should be Some");
4275        assert_eq!(
4276            pool.status().max_size,
4277            1,
4278            "Pool size should be clamped to 1"
4279        );
4280    }
4281
4282    // ── Db extractor tests ───────────────────────────────────────
4283
4284    #[test]
4285    fn database_topology_builds_primary_and_replica_pools() {
4286        let config = DatabaseConfig {
4287            primary_url: Some("postgres://localhost/primary".into()),
4288            replica_url: Some("postgres://localhost/replica".into()),
4289            primary_pool_size: Some(6),
4290            replica_pool_size: Some(2),
4291            ..Default::default()
4292        };
4293
4294        let topology = create_topology(&config)
4295            .expect("topology should build")
4296            .expect("topology should be configured");
4297
4298        assert_eq!(topology.primary().status().max_size, 6);
4299        assert_eq!(
4300            topology.replica().expect("replica pool").status().max_size,
4301            2
4302        );
4303        assert_eq!(topology.read().status().max_size, 2);
4304    }
4305
4306    #[test]
4307    fn database_topology_single_url_builds_only_primary_pool() {
4308        let config = DatabaseConfig {
4309            url: Some("postgres://localhost/single".into()),
4310            pool_size: 5,
4311            ..Default::default()
4312        };
4313
4314        let topology = create_topology(&config)
4315            .expect("topology should build")
4316            .expect("topology should be configured");
4317
4318        assert_eq!(topology.primary().status().max_size, 5);
4319        assert!(topology.replica().is_none());
4320        assert_eq!(topology.read().status().max_size, 5);
4321    }
4322
4323    #[test]
4324    fn config_runtime_drift_pool_applies_connect_timeout_to_wait_and_create() {
4325        let config = DatabaseConfig {
4326            url: Some("postgres://localhost/test".into()),
4327            connect_timeout_secs: 7,
4328            ..Default::default()
4329        };
4330        let pool = create_pool(&config)
4331            .expect("should build pool")
4332            .expect("should be Some");
4333
4334        let timeouts = pool.timeouts();
4335        assert_eq!(timeouts.wait, Some(Duration::from_secs(7)));
4336        assert_eq!(timeouts.create, Some(Duration::from_secs(7)));
4337    }
4338
4339    #[derive(Clone)]
4340    struct TestDbState;
4341
4342    impl DbState for TestDbState {
4343        fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
4344            None
4345        }
4346    }
4347
4348    #[derive(Clone)]
4349    struct TestReadState {
4350        primary: Pool<crate::db::RuntimeConnection>,
4351    }
4352
4353    impl DbState for TestReadState {
4354        fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
4355            Some(&self.primary)
4356        }
4357    }
4358
4359    #[test]
4360    fn database_topology_read_pool_falls_back_to_primary() {
4361        let config = DatabaseConfig {
4362            url: Some("postgres://localhost/read-fallback".into()),
4363            pool_size: 3,
4364            ..Default::default()
4365        };
4366        let primary = create_pool(&config).unwrap().unwrap();
4367        let state = TestReadState { primary };
4368
4369        assert_eq!(state.read_pool().expect("read pool").status().max_size, 3);
4370    }
4371
4372    #[tokio::test]
4373    async fn db_extractor_rejects_when_no_pool() {
4374        use axum::Router;
4375        use axum::body::Body;
4376        use axum::http::{Request, StatusCode};
4377        use axum::routing::get;
4378        use tower::ServiceExt;
4379
4380        async fn handler(_db: Db) -> &'static str {
4381            "ok"
4382        }
4383
4384        let app = Router::new()
4385            .route("/", get(handler))
4386            .with_state(TestDbState);
4387
4388        let response = app
4389            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4390            .await
4391            .unwrap();
4392
4393        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4394    }
4395
4396    #[tokio::test]
4397    async fn database_topology_primary_only_has_no_replica() {
4398        let config = DatabaseConfig {
4399            primary_url: Some("postgres://user:pass@localhost/db".to_string()),
4400            ..DatabaseConfig::default()
4401        };
4402        let topology = create_topology(&config).unwrap().unwrap();
4403
4404        let primary = topology.primary().clone();
4405
4406        let new_topology = DatabaseTopology::primary_only(primary);
4407        assert!(
4408            new_topology.replica().is_none(),
4409            "primary_only must set replica to None"
4410        );
4411    }
4412
4413    #[tokio::test]
4414    async fn database_topology_from_pools_retains_replica() {
4415        let config = DatabaseConfig {
4416            primary_url: Some("postgres://user:pass@localhost/db".to_string()),
4417            replica_url: Some("postgres://user:pass@localhost/db_replica".to_string()),
4418            ..DatabaseConfig::default()
4419        };
4420        let topology = create_topology(&config).unwrap().unwrap();
4421
4422        let primary = topology.primary().clone();
4423        let replica = topology.replica().cloned();
4424
4425        let new_topology = DatabaseTopology::from_pools(primary, replica);
4426        assert!(
4427            new_topology.replica().is_some(),
4428            "from_pools must preserve the replica pool"
4429        );
4430    }
4431
4432    // ── scrub_sql tests ───────────────────────────────────────────────────────
4433
4434    #[test]
4435    fn scrub_sql_strips_string_literals() {
4436        assert_eq!(
4437            super::scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
4438            "SELECT * FROM users WHERE name = '?'"
4439        );
4440    }
4441
4442    #[test]
4443    fn scrub_sql_strips_numeric_literals() {
4444        assert_eq!(
4445            super::scrub_sql("SELECT * FROM orders WHERE id = 42"),
4446            "SELECT * FROM orders WHERE id = ?"
4447        );
4448    }
4449
4450    #[test]
4451    fn scrub_sql_preserves_pg_positional_params() {
4452        assert_eq!(
4453            super::scrub_sql("SELECT * FROM t WHERE x = $1 AND y = $2"),
4454            "SELECT * FROM t WHERE x = $1 AND y = $2"
4455        );
4456    }
4457
4458    #[test]
4459    fn scrub_sql_does_not_stomp_identifiers() {
4460        // "table1" should not be replaced because it's not preceded by a separator
4461        assert_eq!(
4462            super::scrub_sql("SELECT * FROM table1 WHERE active = true"),
4463            "SELECT * FROM table1 WHERE active = true"
4464        );
4465    }
4466
4467    #[test]
4468    fn scrub_sql_multiple_literals_in_one_query() {
4469        assert_eq!(
4470            super::scrub_sql("INSERT INTO users (name, age) VALUES ('Bob', 30)"),
4471            "INSERT INTO users (name, age) VALUES ('?', ?)"
4472        );
4473    }
4474
4475    #[test]
4476    fn scrub_sql_handles_escaped_single_quotes() {
4477        assert_eq!(
4478            super::scrub_sql("SELECT * FROM t WHERE s = 'it''s a test'"),
4479            "SELECT * FROM t WHERE s = '?'"
4480        );
4481    }
4482
4483    #[test]
4484    fn scrub_sql_empty_string() {
4485        assert_eq!(super::scrub_sql(""), "");
4486    }
4487
4488    // ── Bug fixes: exponent suffix, dollar-quoted strings, E-string escapes ──
4489
4490    #[test]
4491    fn scrub_sql_scientific_notation_integer_exponent() {
4492        // 1e6 should be fully redacted to ? (the "e6" part is the exponent)
4493        assert_eq!(
4494            super::scrub_sql("SELECT * FROM t WHERE n = 1e6"),
4495            "SELECT * FROM t WHERE n = ?"
4496        );
4497    }
4498
4499    #[test]
4500    fn scrub_sql_scientific_notation_float_exponent() {
4501        // 2.5E-4 should be fully redacted: digit + decimal + E + sign + digit(s)
4502        assert_eq!(
4503            super::scrub_sql("SELECT * FROM t WHERE n = 2.5E-4"),
4504            "SELECT * FROM t WHERE n = ?"
4505        );
4506    }
4507
4508    #[test]
4509    fn scrub_sql_scientific_notation_uppercase_positive_exponent() {
4510        // 3E+10 — uppercase E with explicit + sign
4511        assert_eq!(
4512            super::scrub_sql("SELECT * FROM t WHERE n = 3E+10"),
4513            "SELECT * FROM t WHERE n = ?"
4514        );
4515    }
4516
4517    #[test]
4518    fn scrub_sql_dollar_quoted_anonymous() {
4519        // $$...$$ dollar-quoted string: content must be fully redacted
4520        assert_eq!(super::scrub_sql("SELECT $$secret value$$"), "SELECT '?'");
4521    }
4522
4523    #[test]
4524    fn scrub_sql_dollar_quoted_with_tag() {
4525        // $tag$...$tag$ — tagged dollar-quoted string
4526        assert_eq!(
4527            super::scrub_sql("SELECT $body$hello world$body$"),
4528            "SELECT '?'"
4529        );
4530    }
4531
4532    #[test]
4533    fn scrub_sql_dollar_quoted_does_not_affect_positional_params() {
4534        // $1, $2 positional params must still pass through unmodified
4535        assert_eq!(
4536            super::scrub_sql("SELECT $1, $2 FROM $$secret$$ WHERE id = $3"),
4537            "SELECT $1, $2 FROM '?' WHERE id = $3"
4538        );
4539    }
4540
4541    #[test]
4542    fn scrub_sql_estring_backslash_escaped_quote() {
4543        // E'it\'s secret' — backslash-escaped quote inside E'' string
4544        assert_eq!(
4545            super::scrub_sql(r"SELECT E'it\'s secret' FROM t"),
4546            "SELECT '?' FROM t"
4547        );
4548    }
4549
4550    #[test]
4551    fn scrub_sql_estring_uppercase() {
4552        // Uppercase E prefix variant E'...'
4553        assert_eq!(
4554            super::scrub_sql("SELECT E'hello world' FROM t"),
4555            "SELECT '?' FROM t"
4556        );
4557    }
4558
4559    #[test]
4560    fn scrub_sql_estring_multiple_backslash_escapes() {
4561        // Multiple backslash sequences inside one E'' literal
4562        assert_eq!(
4563            super::scrub_sql(r"SELECT E'line1\nline2' FROM t"),
4564            "SELECT '?' FROM t"
4565        );
4566    }
4567
4568    #[test]
4569    fn scrub_sql_leading_dot_numeric_literals() {
4570        assert_eq!(super::scrub_sql("SELECT .5"), "SELECT ?");
4571        assert_eq!(super::scrub_sql("SELECT .25 + .75"), "SELECT ? + ?");
4572        assert_eq!(super::scrub_sql("SELECT t.col"), "SELECT t.col");
4573        assert_eq!(
4574            super::scrub_sql("SELECT schema.table.col"),
4575            "SELECT schema.table.col"
4576        );
4577    }
4578
4579    #[test]
4580    fn scrub_sql_estring_doubled_quote_escape() {
4581        assert_eq!(
4582            super::scrub_sql("SELECT E'it''s secret' FROM t"),
4583            "SELECT '?' FROM t"
4584        );
4585    }
4586
4587    #[test]
4588    fn scrub_sql_numeric_literal_underscore_grouping() {
4589        assert_eq!(super::scrub_sql("SELECT 5_432_000"), "SELECT ?");
4590        assert_eq!(super::scrub_sql("SELECT 1_000.5_0"), "SELECT ?");
4591        assert_eq!(super::scrub_sql("SELECT col_5_val"), "SELECT col_5_val");
4592        assert_eq!(super::scrub_sql("SELECT col_5"), "SELECT col_5");
4593    }
4594
4595    // ── Transaction isolation + retry (issue #1202) ─────────────────
4596
4597    #[derive(Debug)]
4598    struct FakeDbErrorInfo {
4599        message: &'static str,
4600    }
4601
4602    impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
4603        fn message(&self) -> &str {
4604            self.message
4605        }
4606        fn details(&self) -> Option<&str> {
4607            None
4608        }
4609        fn hint(&self) -> Option<&str> {
4610            None
4611        }
4612        fn table_name(&self) -> Option<&str> {
4613            None
4614        }
4615        fn column_name(&self) -> Option<&str> {
4616            None
4617        }
4618        fn constraint_name(&self) -> Option<&str> {
4619            None
4620        }
4621        fn statement_position(&self) -> Option<i32> {
4622            None
4623        }
4624    }
4625
4626    fn diesel_db_error(
4627        kind: diesel::result::DatabaseErrorKind,
4628        message: &'static str,
4629    ) -> diesel::result::Error {
4630        diesel::result::Error::DatabaseError(kind, Box::new(FakeDbErrorInfo { message }))
4631    }
4632
4633    // ── TxOptions builder ────────────────────────────────────────
4634
4635    #[test]
4636    fn tx_options_default_is_read_committed_no_retry() {
4637        let opts = TxOptions::default();
4638        assert_eq!(opts.isolation, IsolationLevel::ReadCommitted);
4639        assert!(!opts.read_only);
4640        assert!(!opts.deferrable);
4641        assert_eq!(
4642            opts.max_attempts, 1,
4643            "default must behave exactly like today's tx(): one attempt, no retry"
4644        );
4645    }
4646
4647    #[test]
4648    fn tx_options_serializable_enables_retry() {
4649        let opts = TxOptions::serializable();
4650        assert_eq!(opts.isolation, IsolationLevel::Serializable);
4651        assert!(
4652            opts.max_attempts > 1,
4653            "serializable() should default to retrying since retry is the point"
4654        );
4655    }
4656
4657    #[test]
4658    fn tx_options_repeatable_read_enables_retry() {
4659        let opts = TxOptions::repeatable_read();
4660        assert_eq!(opts.isolation, IsolationLevel::RepeatableRead);
4661        assert!(opts.max_attempts > 1);
4662    }
4663
4664    #[test]
4665    fn tx_options_builder_chains() {
4666        let opts = TxOptions::serializable()
4667            .read_only()
4668            .deferrable()
4669            .max_attempts(9)
4670            .initial_backoff(Duration::from_millis(3))
4671            .max_backoff(Duration::from_millis(30));
4672        assert!(opts.read_only);
4673        assert!(opts.deferrable);
4674        assert_eq!(opts.max_attempts, 9);
4675        assert_eq!(opts.initial_backoff, Duration::from_millis(3));
4676        assert_eq!(opts.max_backoff, Duration::from_millis(30));
4677    }
4678
4679    #[test]
4680    fn tx_options_max_attempts_clamps_to_at_least_one() {
4681        // A zero here must never produce a loop that skips the closure entirely.
4682        assert_eq!(TxOptions::default().max_attempts(0).max_attempts, 1);
4683    }
4684
4685    #[test]
4686    fn tx_options_effective_max_attempts_clamps_struct_literal_bypass() {
4687        // `max_attempts` is a public field, so struct-literal update syntax can
4688        // set it to 0 directly, bypassing the `max_attempts()` builder's clamp.
4689        // The retry loop must still treat this as "run once", not "never run".
4690        let opts = TxOptions {
4691            max_attempts: 0,
4692            ..TxOptions::default()
4693        };
4694        assert_eq!(opts.max_attempts, 0, "the raw field is not itself clamped");
4695        assert_eq!(
4696            opts.effective_max_attempts(),
4697            1,
4698            "the retry loop's accessor must clamp a struct-literal 0 to 1"
4699        );
4700    }
4701
4702    // ── Backoff ──────────────────────────────────────────────────
4703
4704    #[test]
4705    fn retry_backoff_base_doubles_per_attempt() {
4706        let initial = Duration::from_millis(5);
4707        let max = Duration::from_secs(60);
4708        assert_eq!(
4709            retry_backoff_base(initial, max, 1),
4710            Duration::from_millis(5)
4711        );
4712        assert_eq!(
4713            retry_backoff_base(initial, max, 2),
4714            Duration::from_millis(10)
4715        );
4716        assert_eq!(
4717            retry_backoff_base(initial, max, 3),
4718            Duration::from_millis(20)
4719        );
4720        assert_eq!(
4721            retry_backoff_base(initial, max, 4),
4722            Duration::from_millis(40)
4723        );
4724    }
4725
4726    #[test]
4727    fn retry_backoff_base_caps_at_max() {
4728        let initial = Duration::from_millis(5);
4729        let max = Duration::from_millis(50);
4730        // 5,10,20,40 then capped at 50 for all higher attempts.
4731        assert_eq!(
4732            retry_backoff_base(initial, max, 5),
4733            Duration::from_millis(50)
4734        );
4735        assert_eq!(retry_backoff_base(initial, max, 40), max);
4736        // Huge attempt must not panic on shift overflow.
4737        assert_eq!(retry_backoff_base(initial, max, u32::MAX), max);
4738    }
4739
4740    #[test]
4741    fn retry_backoff_delay_stays_within_jitter_bounds_and_cap() {
4742        let initial = Duration::from_millis(100);
4743        let max = Duration::from_secs(10);
4744        for attempt in 1..=6u32 {
4745            let base = retry_backoff_base(initial, max, attempt);
4746            for _ in 0..64 {
4747                let d = retry_backoff_delay(initial, max, attempt);
4748                // Jitter is +/-20% of base, never exceeding the cap.
4749                assert!(
4750                    d >= base.mul_f64(0.8) && d <= base.mul_f64(1.2),
4751                    "delay {d:?} out of jitter bounds for base {base:?}"
4752                );
4753                assert!(d <= max, "delay {d:?} exceeded cap {max:?}");
4754            }
4755        }
4756    }
4757
4758    // ── Retryable-error classification ───────────────────────────
4759
4760    #[test]
4761    fn serialization_failure_is_retryable() {
4762        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4763            diesel::result::DatabaseErrorKind::SerializationFailure,
4764            "could not serialize access due to read/write dependencies among transactions",
4765        ));
4766        assert!(is_retryable_txn_error(&err));
4767    }
4768
4769    #[test]
4770    fn deadlock_is_retryable() {
4771        // Postgres 40P01 is not mapped to a dedicated DatabaseErrorKind, so it
4772        // is caught via an exact match against Postgres's own invariant
4773        // primary message for this condition.
4774        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4775            diesel::result::DatabaseErrorKind::Unknown,
4776            "deadlock detected",
4777        ));
4778        assert!(is_retryable_txn_error(&err));
4779    }
4780
4781    #[test]
4782    fn deadlock_message_is_matched_case_and_whitespace_insensitively() {
4783        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4784            diesel::result::DatabaseErrorKind::Unknown,
4785            "  Deadlock Detected  ",
4786        ));
4787        assert!(is_retryable_txn_error(&err));
4788    }
4789
4790    #[test]
4791    fn unknown_kind_message_merely_mentioning_deadlock_is_not_retryable() {
4792        // Regression (PR #1581 review, round 2): an `Unknown`-kind Postgres
4793        // error from application SQL (e.g. `RAISE EXCEPTION`) whose message
4794        // merely *mentions* "deadlock detected" or "40001" as part of a
4795        // longer business message must not be retried -- only an exact match
4796        // against Postgres's own invariant primary message counts.
4797        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4798            diesel::result::DatabaseErrorKind::Unknown,
4799            "order 40001 could not be processed: deadlock detected in workflow",
4800        ));
4801        assert!(!is_retryable_txn_error(&err));
4802    }
4803
4804    #[test]
4805    fn unknown_kind_40001_text_alone_is_not_retryable() {
4806        // A bare "40001" in an Unknown-kind message is no longer treated as a
4807        // retry signal -- genuine 40001s are already reliably classified via
4808        // the DatabaseErrorKind::SerializationFailure fast path (diesel-async
4809        // maps the real SQLSTATE), so this text pattern is dead weight that
4810        // only added false-positive risk.
4811        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4812            diesel::result::DatabaseErrorKind::Unknown,
4813            "ERROR: 40001: could not serialize access",
4814        ));
4815        assert!(!is_retryable_txn_error(&err));
4816    }
4817
4818    #[test]
4819    fn unique_violation_is_not_retryable() {
4820        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4821            diesel::result::DatabaseErrorKind::UniqueViolation,
4822            "duplicate key value violates unique constraint",
4823        ));
4824        assert!(!is_retryable_txn_error(&err));
4825    }
4826
4827    #[test]
4828    fn unique_violation_with_magic_substring_in_message_is_not_retryable() {
4829        // Regression: a non-retryable `DatabaseErrorKind` must never be
4830        // misclassified just because its message/constraint text happens to
4831        // contain a magic substring like "40001" or "deadlock detected".
4832        let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4833            diesel::result::DatabaseErrorKind::UniqueViolation,
4834            "duplicate key value violates unique constraint \"orders_40001_key\"",
4835        ));
4836        assert!(!is_retryable_txn_error(&err));
4837    }
4838
4839    #[test]
4840    fn plain_internal_error_is_not_retryable() {
4841        let err = AutumnError::internal_server_error_msg("something unrelated broke");
4842        assert!(!is_retryable_txn_error(&err));
4843    }
4844
4845    #[test]
4846    fn domain_error_with_magic_substring_and_no_db_error_is_not_retryable() {
4847        // Regression (PR #1581 review): a plain domain/validation error — no
4848        // diesel::result::Error or tokio_postgres error anywhere in its
4849        // chain — must never be retried just because its message happens to
4850        // contain a magic substring like "40001" or "deadlock detected".
4851        // Retry requires a structured signal, never bare text.
4852        let err = AutumnError::bad_request_msg(
4853            "order 40001 could not be processed: deadlock detected in workflow",
4854        );
4855        assert!(!is_retryable_txn_error(&err));
4856    }
4857
4858    #[derive(Debug, thiserror::Error)]
4859    #[error("app error")]
4860    struct WrappedDbError(#[source] diesel::result::Error);
4861
4862    #[test]
4863    fn serialization_failure_wrapped_in_custom_error_is_retryable() {
4864        // A custom `E` (e.g. an app error enum with a `#[from] diesel::result::Error`
4865        // variant) is not itself a `diesel::result::Error`, so it isn't found by
4866        // a top-level-only downcast. `downcast_chain_ref` walks `source()` to
4867        // find it, so wrapped diesel errors are still classified structurally
4868        // — no need to fall back to message scanning for this common case.
4869        let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
4870            diesel::result::DatabaseErrorKind::SerializationFailure,
4871            "could not serialize access due to read/write dependencies among transactions",
4872        )));
4873        assert!(is_retryable_txn_error(&err));
4874    }
4875
4876    #[test]
4877    fn unique_violation_wrapped_in_custom_error_is_not_retryable() {
4878        let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
4879            diesel::result::DatabaseErrorKind::UniqueViolation,
4880            "duplicate key value violates unique constraint",
4881        )));
4882        assert!(!is_retryable_txn_error(&err));
4883    }
4884
4885    // ── Attempt accounting ───────────────────────────────────────
4886
4887    #[test]
4888    fn retry_decision_retries_until_attempts_exhausted() {
4889        // A retryable error keeps retrying while attempts remain, then stops.
4890        let max = 3;
4891        assert_eq!(retry_decision(1, max, true), RetryDecision::Retry);
4892        assert_eq!(retry_decision(2, max, true), RetryDecision::Retry);
4893        assert_eq!(
4894            retry_decision(3, max, true),
4895            RetryDecision::Stop,
4896            "the final attempt must stop even when the error is retryable (exhausted)"
4897        );
4898    }
4899
4900    #[test]
4901    fn retry_decision_stops_immediately_on_non_retryable() {
4902        assert_eq!(retry_decision(1, 5, false), RetryDecision::Stop);
4903    }
4904
4905    #[test]
4906    fn retry_decision_single_attempt_never_retries() {
4907        // max_attempts == 1 (the default) must behave exactly like today's tx().
4908        assert_eq!(retry_decision(1, 1, true), RetryDecision::Stop);
4909    }
4910}
4911
4912// ── Postgres TLS (sslmode) support ───────────────────────────────────────────
4913
4914/// TLS support for the Postgres pool, driven by `sslmode` in the database URL.
4915///
4916/// diesel-async's default `AsyncPgConnection::establish` hardcodes
4917/// `tokio_postgres::NoTls`, which makes `sslmode=require` fail on every
4918/// connection with "no TLS implementation configured". This module plugs a
4919/// rustls-backed connector into the pool via
4920/// [`diesel_async::pooled_connection::ManagerConfig::custom_setup`] when the
4921/// URL asks for TLS. Both URL (`postgres://…?sslmode=require`) and
4922/// keyword/value (`host=… sslmode=require`) connection strings are
4923/// recognized. The synchronous migration/startup-wait path shares the same
4924/// connector through [`super::establish_migration_connection`] — the
4925/// bundled libpq has no SSL support, so the native `PgConnection` path
4926/// cannot reach TLS-only servers at all.
4927///
4928/// | `sslmode`                     | behavior |
4929/// | ----------------------------- | -------- |
4930/// | absent, `disable`, `prefer`   | unchanged: the default `NoTls` path (plaintext), exactly as before this module existed |
4931/// | `require`                     | TLS. The connection is encrypted and the handshake signatures are verified, but the server's certificate **chain/identity is not** — matching what `libpq`/`psql` do for `require` (self-signed and private-CA servers work) |
4932/// | `verify-full`                 | TLS with full chain **and** hostname verification against the Mozilla root store (`webpki-roots`), plus any `sslrootcert=<PEM file>` from the URL |
4933/// | `verify-ca`                   | rejected with guidance: chain-only verification is not implemented; use `verify-full` (stricter) or `require` |
4934///
4935/// `require` combined with `sslrootcert` is also rejected rather than
4936/// silently ignoring the CA file: `libpq` documents that combination as
4937/// upgrading to certificate verification, so dropping the file would
4938/// silently weaken what the operator asked for.
4939mod tls {
4940    use std::sync::Arc;
4941
4942    use diesel::{ConnectionError, ConnectionResult};
4943    use diesel_async::pooled_connection::SetupCallback;
4944    use diesel_async::{AsyncConnection as _, AsyncPgConnection};
4945    use futures::FutureExt as _;
4946    use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
4947    use rustls::crypto::CryptoProvider;
4948    use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
4949    use rustls::{DigitallySignedStruct, SignatureScheme};
4950    use tokio_postgres_rustls::MakeRustlsConnect;
4951
4952    /// TLS posture derived from the connection string's `sslmode` (and
4953    /// `sslrootcert`). See the [module docs](self) for the full table.
4954    #[derive(Debug, Clone, PartialEq, Eq)]
4955    pub(super) enum TlsPosture {
4956        /// No TLS machinery: keep diesel-async's default `NoTls` setup path.
4957        Off,
4958        /// Encrypt without verifying the server certificate chain
4959        /// (`libpq` parity for `sslmode=require`).
4960        Require,
4961        /// Encrypt and fully verify the certificate chain + hostname.
4962        VerifyFull {
4963            /// Optional `sslrootcert` PEM file to trust in addition to the
4964            /// Mozilla root store.
4965            root_cert: Option<String>,
4966        },
4967        /// A recognized-but-unsupported combination; every connection attempt
4968        /// fails loudly with this reason instead of silently downgrading.
4969        Unsupported { reason: String },
4970    }
4971
4972    impl TlsPosture {
4973        /// Classify a database URL / keyword-value connection string.
4974        pub(super) fn from_database_url(database_url: &str) -> Self {
4975            let params = ssl_params(database_url);
4976            // Last occurrence wins, matching libpq/tokio-postgres semantics.
4977            let get = |key: &str| {
4978                params
4979                    .iter()
4980                    .rev()
4981                    .find(|(k, _)| k == key)
4982                    .map(|(_, v)| v.as_str())
4983            };
4984            let root_cert = get("sslrootcert").map(str::to_owned);
4985            match get("sslmode") {
4986                Some("require") => {
4987                    if root_cert.is_some() {
4988                        Self::Unsupported {
4989                            reason: "sslmode=require with sslrootcert is not supported: \
4990                                     PostgreSQL treats that combination as requiring \
4991                                     certificate verification against the CA file, which \
4992                                     this pool implements only for sslmode=verify-full. \
4993                                     Use sslmode=verify-full to verify the certificate, or \
4994                                     sslmode=require without sslrootcert to encrypt without \
4995                                     verifying it."
4996                                .to_owned(),
4997                        }
4998                    } else {
4999                        Self::Require
5000                    }
5001                }
5002                Some("verify-full") => Self::VerifyFull { root_cert },
5003                Some("verify-ca") => Self::Unsupported {
5004                    reason: "sslmode=verify-ca is not supported: chain-only verification \
5005                             (without hostname checking) is not implemented. Use \
5006                             sslmode=verify-full (stricter) or sslmode=require (encrypts \
5007                             without verifying the certificate)."
5008                        .to_owned(),
5009                },
5010                // Absent, `disable`, `prefer`, or anything unrecognized:
5011                // preserve the pre-TLS behavior exactly (including the error
5012                // tokio-postgres itself raises for invalid values).
5013                _ => Self::Off,
5014            }
5015        }
5016    }
5017
5018    /// Build the pool's custom connection-setup callback for a TLS posture.
5019    // Only the default (Postgres) `build_pool` arm installs this custom TLS
5020    // setup; unused in a `--features sqlite` build (SQLite has no TLS transport).
5021    #[cfg_attr(feature = "sqlite", allow(dead_code))]
5022    pub(super) fn setup_callback(posture: TlsPosture) -> SetupCallback<AsyncPgConnection> {
5023        Box::new(move |url: &str| {
5024            let posture = posture.clone();
5025            let url = url.to_owned();
5026            async move { establish(&url, posture).await }.boxed()
5027        })
5028    }
5029
5030    /// Establish an [`AsyncPgConnection`] honoring `posture` — the single
5031    /// connect path shared by the pool's setup callback and the synchronous
5032    /// migration/wait-check wrapper
5033    /// ([`super::establish_migration_connection`]).
5034    pub(super) async fn establish(
5035        url: &str,
5036        posture: TlsPosture,
5037    ) -> ConnectionResult<AsyncPgConnection> {
5038        match posture {
5039            // Defensive: `build_pool` never installs the callback for
5040            // `Off`, but fall back to the stock path if it ever does.
5041            TlsPosture::Off => AsyncPgConnection::establish(url).await,
5042            TlsPosture::Require => connect_with(url, relaxed_connector()?).await,
5043            TlsPosture::VerifyFull { root_cert } => {
5044                // tokio-postgres's own connection-string parser
5045                // rejects `verify-*` and `sslrootcert`, so hand it a
5046                // sanitized string; the real verification lives in
5047                // the rustls config.
5048                let sanitized = sanitize_for_verify_full(url);
5049                connect_with(&sanitized, verifying_connector(root_cert.as_deref())?).await
5050            }
5051            TlsPosture::Unsupported { reason } => {
5052                Err(ConnectionError::InvalidConnectionUrl(reason))
5053            }
5054        }
5055    }
5056
5057    async fn connect_with(
5058        url: &str,
5059        tls: MakeRustlsConnect,
5060    ) -> ConnectionResult<AsyncPgConnection> {
5061        let (client, connection) = tokio_postgres::connect(url, tls).await.map_err(|e| {
5062            // tokio_postgres's Display gives only the error kind ("error
5063            // performing TLS handshake"); append the source so operators see
5064            // the actionable cause ("server does not support TLS", a
5065            // certificate verification failure, …).
5066            let msg = std::error::Error::source(&e)
5067                .map_or_else(|| e.to_string(), |source| format!("{e}: {source}"));
5068            ConnectionError::BadConnection(msg)
5069        })?;
5070        AsyncPgConnection::try_from_client_and_connection(client, connection).await
5071    }
5072
5073    /// Encrypt-only connector for `sslmode=require`: handshake signatures are
5074    /// verified but the certificate chain/identity is not, mirroring
5075    /// `libpq`/`psql` behavior for `require` (which self-signed and
5076    /// private-CA deployments rely on).
5077    fn relaxed_connector() -> Result<MakeRustlsConnect, ConnectionError> {
5078        let provider = Arc::new(rustls::crypto::ring::default_provider());
5079        let config = rustls::ClientConfig::builder_with_provider(provider.clone())
5080            .with_safe_default_protocol_versions()
5081            .map_err(|e| {
5082                ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
5083            })?
5084            .dangerous()
5085            .with_custom_certificate_verifier(Arc::new(NoServerCertVerification(
5086                (*provider).clone(),
5087            )))
5088            .with_no_client_auth();
5089        Ok(MakeRustlsConnect::new(config))
5090    }
5091
5092    /// Fully verifying connector for `sslmode=verify-full`: certificate chain
5093    /// and hostname are checked against the Mozilla root store plus any
5094    /// `sslrootcert` PEM file from the connection string. `webpki-roots` is
5095    /// used (rather than the platform store) so behavior is deterministic on
5096    /// every target, including mobile, where no native store is reachable.
5097    fn verifying_connector(root_cert: Option<&str>) -> Result<MakeRustlsConnect, ConnectionError> {
5098        let mut roots = rustls::RootCertStore::empty();
5099        roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
5100        if let Some(path) = root_cert {
5101            use rustls_pki_types::pem::PemObject as _;
5102            let certs = CertificateDer::pem_file_iter(path).map_err(|e| {
5103                ConnectionError::BadConnection(format!("failed to read sslrootcert {path}: {e}"))
5104            })?;
5105            for cert in certs {
5106                let cert = cert.map_err(|e| {
5107                    ConnectionError::BadConnection(format!(
5108                        "failed to parse sslrootcert {path}: {e}"
5109                    ))
5110                })?;
5111                roots.add(cert).map_err(|e| {
5112                    ConnectionError::BadConnection(format!(
5113                        "failed to trust sslrootcert {path}: {e}"
5114                    ))
5115                })?;
5116            }
5117        }
5118        let provider = Arc::new(rustls::crypto::ring::default_provider());
5119        let config = rustls::ClientConfig::builder_with_provider(provider)
5120            .with_safe_default_protocol_versions()
5121            .map_err(|e| {
5122                ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
5123            })?
5124            .with_root_certificates(roots)
5125            .with_no_client_auth();
5126        Ok(MakeRustlsConnect::new(config))
5127    }
5128
5129    /// Whether tokio-postgres would parse this connection string as a URL —
5130    /// see [`crate::pg_conn_str::is_url`]. Getting this wrong would send
5131    /// keyword strings whose values embed URL-like tokens
5132    /// (`password=https://…`) down the URL path, where `url::Url::parse`
5133    /// fails and the TLS posture silently falls back to [`TlsPosture::Off`].
5134    /// Shared with config validation so the two never disagree about which
5135    /// strings are reachable.
5136    use crate::pg_conn_str::is_url as is_url_connection_string;
5137    /// Parse a libpq-style `key = value` connection string, mirroring
5138    /// tokio-postgres's (private) parser — see
5139    /// [`crate::pg_conn_str::keyword_value_pairs`]. Naive whitespace
5140    /// splitting would miss `sslmode` in quoted/spaced strings and silently
5141    /// downgrade the TLS posture to [`TlsPosture::Off`].
5142    use crate::pg_conn_str::keyword_value_pairs;
5143
5144    /// Extract query/keyword parameters relevant to TLS from either a
5145    /// `postgres://…` URL or a `key=value …` connection string.
5146    fn ssl_params(database_url: &str) -> Vec<(String, String)> {
5147        if is_url_connection_string(database_url) {
5148            url::Url::parse(database_url)
5149                .map(|u| {
5150                    u.query_pairs()
5151                        .map(|(k, v)| (k.into_owned(), v.into_owned()))
5152                        .collect()
5153                })
5154                .unwrap_or_default()
5155        } else {
5156            keyword_value_pairs(database_url).unwrap_or_default()
5157        }
5158    }
5159
5160    /// Serialize one value of a keyword/value connection string, quoting it
5161    /// whenever it would not survive re-parsing as a bare token.
5162    fn quote_keyword_value(value: &str) -> String {
5163        if !value.is_empty()
5164            && !value.contains(|c: char| c.is_whitespace() || c == '\'' || c == '\\')
5165        {
5166            return value.to_owned();
5167        }
5168        let mut quoted = String::with_capacity(value.len() + 2);
5169        quoted.push('\'');
5170        for c in value.chars() {
5171            if c == '\'' || c == '\\' {
5172                quoted.push('\\');
5173            }
5174            quoted.push(c);
5175        }
5176        quoted.push('\'');
5177        quoted
5178    }
5179
5180    /// Rewrite a `verify-full` connection string into one tokio-postgres can
5181    /// parse: `sslmode` downgraded to `require` (the handshake decision), and
5182    /// `sslrootcert` removed (consumed by [`verifying_connector`] instead).
5183    fn sanitize_for_verify_full(database_url: &str) -> String {
5184        if is_url_connection_string(database_url) {
5185            let Ok(mut parsed) = url::Url::parse(database_url) else {
5186                return database_url.to_owned();
5187            };
5188            // Rewrite ONLY the sslmode/sslrootcert components, preserving
5189            // every other raw query component byte-for-byte: decoding and
5190            // re-serializing through query_pairs()/append_pair() would
5191            // form-encode spaces as `+`, which libpq/tokio-postgres do not
5192            // accept in Postgres URI parameters —
5193            // `options=-c%20search_path%3Dtenant` would be mangled into
5194            // `options=-c+search_path=tenant`. (The startup-wait splice in
5195            // migrate.rs avoids those APIs for the same reason.)
5196            let raw = parsed
5197                .query()
5198                .unwrap_or("")
5199                .split('&')
5200                .filter(|component| !component.is_empty())
5201                .filter_map(|component| {
5202                    let key = component.split('=').next().unwrap_or(component);
5203                    match key {
5204                        "sslmode" => Some("sslmode=require"),
5205                        "sslrootcert" => None,
5206                        _ => Some(component),
5207                    }
5208                })
5209                .collect::<Vec<_>>()
5210                .join("&");
5211            if raw.is_empty() {
5212                parsed.set_query(None);
5213            } else {
5214                parsed.set_query(Some(&raw));
5215            }
5216            parsed.to_string()
5217        } else {
5218            let Some(pairs) = keyword_value_pairs(database_url) else {
5219                // Malformed: pass through so tokio-postgres reports its own
5220                // parse error instead of us inventing a different string.
5221                return database_url.to_owned();
5222            };
5223            pairs
5224                .into_iter()
5225                .filter_map(|(k, v)| match k.as_str() {
5226                    "sslmode" => Some("sslmode=require".to_owned()),
5227                    "sslrootcert" => None,
5228                    _ => Some(format!("{k}={}", quote_keyword_value(&v))),
5229                })
5230                .collect::<Vec<_>>()
5231                .join(" ")
5232        }
5233    }
5234
5235    /// Accepts any server certificate without validating its chain or
5236    /// hostname, while still cryptographically verifying the TLS handshake
5237    /// signatures (the connection is genuinely encrypted to *some* holder of
5238    /// the presented key — "no identity check", not "no security"). This is
5239    /// exactly `libpq`/`psql`'s posture for `sslmode=require`; identity
5240    /// verification is `sslmode=verify-full`'s job.
5241    #[derive(Debug)]
5242    struct NoServerCertVerification(CryptoProvider);
5243
5244    impl ServerCertVerifier for NoServerCertVerification {
5245        fn verify_server_cert(
5246            &self,
5247            _end_entity: &CertificateDer<'_>,
5248            _intermediates: &[CertificateDer<'_>],
5249            _server_name: &ServerName<'_>,
5250            _ocsp_response: &[u8],
5251            _now: UnixTime,
5252        ) -> Result<ServerCertVerified, rustls::Error> {
5253            Ok(ServerCertVerified::assertion())
5254        }
5255
5256        fn verify_tls12_signature(
5257            &self,
5258            message: &[u8],
5259            cert: &CertificateDer<'_>,
5260            dss: &DigitallySignedStruct,
5261        ) -> Result<HandshakeSignatureValid, rustls::Error> {
5262            rustls::crypto::verify_tls12_signature(
5263                message,
5264                cert,
5265                dss,
5266                &self.0.signature_verification_algorithms,
5267            )
5268        }
5269
5270        fn verify_tls13_signature(
5271            &self,
5272            message: &[u8],
5273            cert: &CertificateDer<'_>,
5274            dss: &DigitallySignedStruct,
5275        ) -> Result<HandshakeSignatureValid, rustls::Error> {
5276            rustls::crypto::verify_tls13_signature(
5277                message,
5278                cert,
5279                dss,
5280                &self.0.signature_verification_algorithms,
5281            )
5282        }
5283
5284        fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
5285            self.0.signature_verification_algorithms.supported_schemes()
5286        }
5287    }
5288
5289    #[cfg(test)]
5290    mod tests {
5291        use super::*;
5292
5293        #[test]
5294        fn absent_disable_and_prefer_keep_the_default_notls_path() {
5295            for url in [
5296                "postgres://user:pass@db.example.com:5432/app",
5297                "postgres://user:pass@db.example.com:5432/app?sslmode=disable",
5298                "postgres://user:pass@db.example.com:5432/app?sslmode=prefer",
5299                "host=db.example.com user=user",
5300                "host=db.example.com user=user sslmode=disable",
5301            ] {
5302                assert_eq!(
5303                    TlsPosture::from_database_url(url),
5304                    TlsPosture::Off,
5305                    "{url} must keep the default NoTls path"
5306                );
5307            }
5308        }
5309
5310        #[test]
5311        fn unrecognized_sslmode_values_keep_the_default_path_and_its_errors() {
5312            // tokio-postgres itself rejects these at connect time; the pool
5313            // must not mask that with a different TLS decision.
5314            assert_eq!(
5315                TlsPosture::from_database_url("postgres://u@h/db?sslmode=allow"),
5316                TlsPosture::Off
5317            );
5318            assert_eq!(
5319                TlsPosture::from_database_url("postgres://u@h/db?sslmode=bogus"),
5320                TlsPosture::Off
5321            );
5322        }
5323
5324        #[test]
5325        fn require_selects_the_relaxed_tls_connector() {
5326            assert_eq!(
5327                TlsPosture::from_database_url(
5328                    "postgres://user:pass@db.example.com:5432/app?sslmode=require"
5329                ),
5330                TlsPosture::Require
5331            );
5332            assert_eq!(
5333                TlsPosture::from_database_url("host=db.example.com sslmode=require"),
5334                TlsPosture::Require
5335            );
5336        }
5337
5338        #[test]
5339        fn verify_full_selects_the_verifying_connector_with_optional_root() {
5340            assert_eq!(
5341                TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-full"),
5342                TlsPosture::VerifyFull { root_cert: None }
5343            );
5344            assert_eq!(
5345                TlsPosture::from_database_url(
5346                    "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem"
5347                ),
5348                TlsPosture::VerifyFull {
5349                    root_cert: Some("/etc/ca.pem".to_owned())
5350                }
5351            );
5352        }
5353
5354        #[test]
5355        fn keyword_strings_with_spaces_around_equals_are_parsed() {
5356            // Regression: whitespace-splitting missed `sslmode` here and
5357            // silently downgraded the posture to Off (plaintext).
5358            assert_eq!(
5359                TlsPosture::from_database_url("host=db user=u sslmode = require"),
5360                TlsPosture::Require
5361            );
5362            assert_eq!(
5363                TlsPosture::from_database_url("sslmode = require"),
5364                TlsPosture::Require
5365            );
5366            assert_eq!(
5367                TlsPosture::from_database_url("host = db sslmode\t=\nverify-full user=u"),
5368                TlsPosture::VerifyFull { root_cert: None }
5369            );
5370        }
5371
5372        #[test]
5373        fn keyword_strings_with_quoted_values_are_parsed() {
5374            assert_eq!(
5375                TlsPosture::from_database_url("sslmode='verify-full'"),
5376                TlsPosture::VerifyFull { root_cert: None }
5377            );
5378            assert_eq!(
5379                TlsPosture::from_database_url(
5380                    "host=db sslmode='verify-full' sslrootcert='/path with space/ca.pem'"
5381                ),
5382                TlsPosture::VerifyFull {
5383                    root_cert: Some("/path with space/ca.pem".to_owned())
5384                }
5385            );
5386            // Backslash escapes work inside and outside quotes, as in
5387            // tokio-postgres/libpq.
5388            assert_eq!(
5389                TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert='/pki/it\'s.pem'"),
5390                TlsPosture::VerifyFull {
5391                    root_cert: Some("/pki/it's.pem".to_owned())
5392                }
5393            );
5394            assert_eq!(
5395                TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert=/pki/my\ ca.pem"),
5396                TlsPosture::VerifyFull {
5397                    root_cert: Some("/pki/my ca.pem".to_owned())
5398                }
5399            );
5400        }
5401
5402        #[test]
5403        fn malformed_keyword_strings_keep_the_default_path_and_its_errors() {
5404            // tokio-postgres rejects these at connect time; the posture must
5405            // not guess a different TLS decision for them.
5406            for url in [
5407                "host=db sslmode",            // missing `=` and value
5408                "host=db sslmode=",           // empty unquoted value
5409                "host=db sslmode='require",   // unterminated quote
5410                r"host=db sslmode='require\", // EOF right after escape
5411            ] {
5412                assert_eq!(
5413                    TlsPosture::from_database_url(url),
5414                    TlsPosture::Off,
5415                    "{url:?} must keep the default NoTls path"
5416                );
5417            }
5418        }
5419
5420        #[test]
5421        fn keyword_strings_with_url_like_values_stay_on_the_keyword_path() {
5422            // Regression: `contains("://")` sent this whole string to
5423            // url::Url::parse, which failed, so sslmode=require silently
5424            // became Off (plaintext). Only `postgres://`/`postgresql://`
5425            // prefixes are URLs, exactly as in tokio-postgres.
5426            assert_eq!(
5427                TlsPosture::from_database_url("host=db password=https://secret sslmode=require"),
5428                TlsPosture::Require
5429            );
5430            assert_eq!(
5431                TlsPosture::from_database_url(
5432                    "host=db options='-c foo=bar://baz' sslmode='verify-full'"
5433                ),
5434                TlsPosture::VerifyFull { root_cert: None }
5435            );
5436            // The alternate scheme spelling is still parsed as a URL.
5437            assert_eq!(
5438                TlsPosture::from_database_url("postgresql://u@h/db?sslmode=require"),
5439                TlsPosture::Require
5440            );
5441        }
5442
5443        #[test]
5444        fn verify_ca_and_require_with_rootcert_fail_loudly() {
5445            assert!(matches!(
5446                TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-ca"),
5447                TlsPosture::Unsupported { .. }
5448            ));
5449            assert!(matches!(
5450                TlsPosture::from_database_url(
5451                    "postgres://u@h/db?sslmode=require&sslrootcert=/etc/ca.pem"
5452                ),
5453                TlsPosture::Unsupported { .. }
5454            ));
5455        }
5456
5457        #[test]
5458        fn last_sslmode_occurrence_wins() {
5459            assert_eq!(
5460                TlsPosture::from_database_url("postgres://u@h/db?sslmode=disable&sslmode=require"),
5461                TlsPosture::Require
5462            );
5463        }
5464
5465        #[test]
5466        fn sanitize_rewrites_verify_full_and_drops_sslrootcert() {
5467            let sanitized = sanitize_for_verify_full(
5468                "postgres://u@h:5432/db?application_name=app&sslmode=verify-full&sslrootcert=/etc/ca.pem",
5469            );
5470            assert!(sanitized.contains("sslmode=require"), "{sanitized}");
5471            assert!(!sanitized.contains("verify-full"), "{sanitized}");
5472            assert!(!sanitized.contains("sslrootcert"), "{sanitized}");
5473            assert!(sanitized.contains("application_name=app"), "{sanitized}");
5474
5475            // Percent-encoded components survive byte-for-byte: form
5476            // re-encoding would turn %20 into `+`, which libpq and
5477            // tokio-postgres do not accept as a space in URI parameters.
5478            let sanitized = sanitize_for_verify_full(
5479                "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=verify-full&sslrootcert=/etc/ca.pem",
5480            );
5481            assert_eq!(
5482                sanitized, "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=require",
5483                "raw components must be preserved verbatim"
5484            );
5485            // Dropping the only params leaves a clean URL with no `?`.
5486            assert_eq!(
5487                sanitize_for_verify_full("postgres://u@h/db?sslrootcert=/etc/ca.pem"),
5488                "postgres://u@h/db"
5489            );
5490
5491            let kv = sanitize_for_verify_full(
5492                "host=h user=u sslmode=verify-full sslrootcert=/etc/ca.pem dbname=db",
5493            );
5494            assert_eq!(kv, "host=h user=u sslmode=require dbname=db");
5495
5496            // Whitespace/quoting variants normalize to a string
5497            // tokio-postgres parses to the same parameters.
5498            let kv = sanitize_for_verify_full(
5499                r"host=h sslmode = 'verify-full' sslrootcert='/path with space/ca.pem' password='it\'s'",
5500            );
5501            assert_eq!(kv, r"host=h sslmode=require password='it\'s'");
5502
5503            // URL-like values must not push a keyword string onto the URL
5504            // branch (which would pass it through unsanitized).
5505            let kv = sanitize_for_verify_full(
5506                "host=h password=https://secret sslmode=verify-full sslrootcert=/etc/ca.pem",
5507            );
5508            assert_eq!(kv, "host=h password=https://secret sslmode=require");
5509        }
5510
5511        #[test]
5512        fn verifying_connector_builds_with_and_without_root_cert_file() {
5513            assert!(verifying_connector(None).is_ok());
5514            assert!(
5515                verifying_connector(Some("/nonexistent/ca.pem")).is_err(),
5516                "an unreadable sslrootcert must fail loudly"
5517            );
5518        }
5519
5520        #[test]
5521        fn relaxed_connector_builds() {
5522            assert!(relaxed_connector().is_ok());
5523        }
5524    }
5525}
5526
5527// ── Synchronous TLS-aware connections (migrations, wait checks) ───────────────
5528
5529/// A synchronous diesel Pg connection for migrations and startup wait
5530/// checks, honoring the connection string's `sslmode` (issue #1585 review).
5531///
5532/// diesel's native [`diesel::PgConnection`] connects through libpq, and the
5533/// workspace bundles libpq **without** SSL support (`pq-sys`'s
5534/// `bundled_without_openssl`) — so with `sslmode=require`/`verify-full` the
5535/// async pool (rustls) connects fine while the sync migration path fails at
5536/// startup before the app ever serves. For those postures this wraps an
5537/// [`AsyncPgConnection`] established through the pool's own rustls connector
5538/// in diesel-async's [`AsyncConnectionWrapper`], which provides the full
5539/// sync diesel API including `MigrationHarness`. With TLS off the native
5540/// `PgConnection` path is kept byte-identical to the historical behavior.
5541///
5542/// [`AsyncConnectionWrapper`]:
5543///     diesel_async::async_connection_wrapper::AsyncConnectionWrapper
5544#[allow(clippy::large_enum_variant)] // short-lived, one per migration target
5545pub(crate) enum MigrationConnection {
5546    /// The historical libpq-backed connection (TLS posture `Off`).
5547    Native(diesel::PgConnection),
5548    /// rustls-backed sync wrapper (TLS posture `Require`/`VerifyFull`).
5549    Rustls {
5550        /// Runtime owning the connection's tokio driver task when none was
5551        /// ambient (plain sync contexts like the CLI); `None` when an
5552        /// ambient runtime drives it (`spawn_blocking` contexts). Callers
5553        /// must keep this alive as long as `conn` — dropping the runtime
5554        /// kills the driver and every query after that hangs or errors.
5555        runtime: Option<tokio::runtime::Runtime>,
5556        conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper<AsyncPgConnection>,
5557    },
5558}
5559
5560/// Whether migrations/wait checks for `database_url` must go through the
5561/// rustls wrapper rather than the native libpq path. Split out of
5562/// [`establish_migration_connection`] so the path selection is unit-testable
5563/// without a server.
5564fn migration_connection_needs_rustls(database_url: &str) -> bool {
5565    !matches!(
5566        tls::TlsPosture::from_database_url(database_url),
5567        tls::TlsPosture::Off
5568    )
5569}
5570
5571/// Establish a [`MigrationConnection`] for `database_url`.
5572///
5573/// **Never call from an async executor thread**: the rustls arm `block_on`s
5574/// connection setup. Sync contexts (the CLI) and `spawn_blocking` tasks (the
5575/// startup migration path) are both fine.
5576///
5577/// # Errors
5578///
5579/// Returns the underlying [`diesel::ConnectionError`] when the connection
5580/// cannot be established (including [`TlsPosture::Unsupported`] postures,
5581/// which fail with their documented guidance).
5582///
5583/// [`TlsPosture::Unsupported`]: tls::TlsPosture::Unsupported
5584pub(crate) fn establish_migration_connection(
5585    database_url: &str,
5586) -> Result<MigrationConnection, diesel::ConnectionError> {
5587    use diesel::Connection as _;
5588    if !migration_connection_needs_rustls(database_url) {
5589        return diesel::PgConnection::establish(database_url).map(MigrationConnection::Native);
5590    }
5591    let posture = tls::TlsPosture::from_database_url(database_url);
5592    // The driver task tokio::spawn()ed during establish must stay driven for
5593    // the connection's lifetime: reuse the ambient runtime when there is one
5594    // (spawn_blocking context), otherwise create one and keep it alive
5595    // alongside the connection.
5596    let (runtime, handle) = if let Ok(handle) = tokio::runtime::Handle::try_current() {
5597        (None, handle)
5598    } else {
5599        let runtime = tokio::runtime::Builder::new_multi_thread()
5600            .worker_threads(1)
5601            .enable_all()
5602            .build()
5603            .map_err(|e| {
5604                diesel::ConnectionError::BadConnection(format!(
5605                    "failed to build a tokio runtime for the TLS migration connection: {e}"
5606                ))
5607            })?;
5608        let handle = runtime.handle().clone();
5609        (Some(runtime), handle)
5610    };
5611    let inner = handle.block_on(tls::establish(database_url, posture))?;
5612    Ok(MigrationConnection::Rustls {
5613        runtime,
5614        conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper::from(inner),
5615    })
5616}
5617
5618/// Establish a synchronous diesel [`diesel::SqliteConnection`] for the `SQLite`
5619/// startup-migration path (issue #1614, PR3).
5620///
5621/// The `SQLite` counterpart to [`establish_migration_connection`], and much
5622/// thinner: `SQLite` is a single-writer local database, so there is no TLS
5623/// negotiation and no advisory-lock connection wrapper — diesel's
5624/// `MigrationHarness` runs directly on a plain `SqliteConnection`. The URL is
5625/// normalized through the same [`normalize_sqlite_target`] the runtime pool
5626/// uses, so the migration connection and the pool open the same database file.
5627///
5628/// The connection sets `PRAGMA busy_timeout = 5000` right after opening,
5629/// mirroring the runtime pool's per-connection `custom_setup` (see
5630/// [`build_sqlite_pool`]): without it, a second concurrent migrator — or a write
5631/// lock briefly held by the runtime pool — makes this connection fail
5632/// *immediately* with `SQLITE_BUSY`, and `auto_migrate_sqlite` exits the process.
5633/// With the timeout, migration statements WAIT up to 5s for the lock to clear
5634/// instead of aborting; diesel migrations are idempotent, so a migrator that
5635/// waits and then finds migrations already applied is fine. Only `busy_timeout`
5636/// is set here — NOT `foreign_keys`/`journal_mode`, because `foreign_keys = ON`
5637/// can break table-recreating migrations.
5638///
5639/// # Errors
5640///
5641/// Returns the underlying [`diesel::ConnectionError`] when the database cannot
5642/// be opened, or [`diesel::ConnectionError::CouldntSetupConfiguration`] if the
5643/// `busy_timeout` pragma cannot be applied.
5644#[cfg(feature = "sqlite")]
5645pub(crate) fn establish_sqlite_migration_connection(
5646    database_url: &str,
5647) -> Result<diesel::SqliteConnection, diesel::ConnectionError> {
5648    use diesel::Connection as _;
5649    use diesel::connection::SimpleConnection as _;
5650    let mut conn = diesel::SqliteConnection::establish(&normalize_sqlite_target(database_url))?;
5651    conn.batch_execute("PRAGMA busy_timeout = 5000;")
5652        .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
5653    Ok(conn)
5654}
5655
5656#[cfg(test)]
5657mod migration_connection_tests {
5658    use super::migration_connection_needs_rustls;
5659
5660    #[test]
5661    fn migration_path_selection_follows_the_tls_posture() {
5662        // NoTls URLs keep the historical native libpq path byte-identical.
5663        for url in [
5664            "postgres://u@h/db",
5665            "postgres://u@h/db?sslmode=disable",
5666            "postgres://u@h/db?sslmode=prefer",
5667            "host=db user=u",
5668        ] {
5669            assert!(
5670                !migration_connection_needs_rustls(url),
5671                "must stay on the native path: {url}"
5672            );
5673        }
5674        // TLS-requiring strings (URL and keyword forms) — and unsupported
5675        // postures, which must fail with the TLS module's guidance instead
5676        // of libpq's — go through the rustls wrapper.
5677        for url in [
5678            "postgres://u@h/db?sslmode=require",
5679            "postgres://u@h/db?sslmode=verify-full",
5680            "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem",
5681            "postgres://u@h/db?sslmode=verify-ca",
5682            "host=db user=u sslmode=require",
5683            "host=db sslmode = 'verify-full'",
5684        ] {
5685            assert!(
5686                migration_connection_needs_rustls(url),
5687                "must use the rustls wrapper: {url}"
5688            );
5689        }
5690    }
5691}