Skip to main content

waypoint_core/
db.rs

1//! Database connection, TLS support, advisory locking, and transaction execution.
2//!
3//! The functions in this module that take `&tokio_postgres::Client` are gated
4//! behind the `postgres` feature and are the original PostgreSQL-only entry points.
5//! New code paths should use [`DbClient`] which abstracts over the configured
6//! backend (PostgreSQL or MySQL).
7
8use crate::dialect::{DatabaseDialect, DialectKind};
9use crate::error::{Result, WaypointError};
10
11#[cfg(feature = "postgres")]
12use fastrand;
13
14#[cfg(feature = "postgres")]
15use tokio_postgres::Client;
16
17#[cfg(feature = "postgres")]
18use crate::config::SslMode;
19
20/// Quote a SQL identifier to prevent SQL injection.
21///
22/// Doubles any embedded double-quotes and wraps in double-quotes — this is the
23/// PostgreSQL convention. For MySQL identifier quoting use the dialect's
24/// [`DatabaseDialect::quote_ident`].
25pub fn quote_ident(name: &str) -> String {
26    format!("\"{}\"", name.replace('"', "\"\""))
27}
28
29/// Quote a SQL identifier the MySQL way: backticks, with embedded backticks
30/// doubled.
31///
32/// The MySQL command paths build DDL from names read out of
33/// `information_schema`. Those are server-provided rather than user-supplied,
34/// but an identifier containing a backtick would still produce broken SQL, and
35/// quoting uniformly means no call site has to reason about which names are
36/// "safe". Mirrors [`quote_ident`], which does the same for PostgreSQL.
37pub fn quote_ident_mysql(name: &str) -> String {
38    format!("`{}`", name.replace('`', "``"))
39}
40
41/// Validate that a SQL identifier contains only safe characters.
42///
43/// Returns an error for names with characters outside `[a-zA-Z0-9_]`.
44/// Even with quoting (defense in depth), we reject suspicious identifiers early.
45pub fn validate_identifier(name: &str) -> Result<()> {
46    if name.is_empty() {
47        return Err(WaypointError::ConfigError(
48            "Identifier cannot be empty".to_string(),
49        ));
50    }
51    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
52        return Err(WaypointError::ConfigError(format!(
53            "Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
54            name
55        )));
56    }
57    Ok(())
58}
59
60/// Engine-specific database connection wrapper.
61///
62/// Constructed by [`Waypoint::new`](crate::Waypoint::new) (which auto-detects
63/// the engine from the connection URL) or by [`DbClient::with_postgres`] /
64/// [`DbClient::with_mysql`] for callers that already have a connection.
65///
66/// Most internal command code currently still operates on a raw
67/// `tokio_postgres::Client` obtained via [`Self::as_postgres`]. As MySQL support
68/// rolls out command-by-command, those call sites move to dialect-aware code.
69pub enum DbClient {
70    /// PostgreSQL connection.
71    #[cfg(feature = "postgres")]
72    Postgres(Client),
73    /// MySQL connection pool. We use a pool because `mysql_async::Conn` requires
74    /// `&mut self` for queries, which would force every command to take
75    /// `&mut DbClient` — disruptive to the existing API. The pool exposes a
76    /// `&self` checkout API.
77    #[cfg(feature = "mysql")]
78    Mysql(mysql_async::Pool),
79}
80
81impl DbClient {
82    /// Wrap an existing PostgreSQL client.
83    #[cfg(feature = "postgres")]
84    pub fn with_postgres(client: Client) -> Self {
85        DbClient::Postgres(client)
86    }
87
88    /// Wrap an existing MySQL pool.
89    #[cfg(feature = "mysql")]
90    pub fn with_mysql(pool: mysql_async::Pool) -> Self {
91        DbClient::Mysql(pool)
92    }
93
94    /// Identify which dialect this connection is for.
95    pub fn dialect_kind(&self) -> DialectKind {
96        match self {
97            #[cfg(feature = "postgres")]
98            DbClient::Postgres(_) => DialectKind::Postgres,
99            #[cfg(feature = "mysql")]
100            DbClient::Mysql(_) => DialectKind::Mysql,
101        }
102    }
103
104    /// Borrow the dialect helper for this connection.
105    ///
106    /// Both `PostgresDialect` and `MysqlDialect` are zero-sized, so this returns
107    /// a static reference rather than allocating a new `Box` per call.
108    pub fn dialect(&self) -> &'static dyn DatabaseDialect {
109        #[cfg(feature = "postgres")]
110        static PG: crate::dialect::postgres::PostgresDialect =
111            crate::dialect::postgres::PostgresDialect;
112        #[cfg(feature = "mysql")]
113        static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
114        match self.dialect_kind() {
115            #[cfg(feature = "postgres")]
116            DialectKind::Postgres => &PG,
117            #[cfg(not(feature = "postgres"))]
118            DialectKind::Postgres => {
119                panic!("PostgreSQL connection without `postgres` feature compiled in")
120            }
121            #[cfg(feature = "mysql")]
122            DialectKind::Mysql => &MY,
123            #[cfg(not(feature = "mysql"))]
124            DialectKind::Mysql => {
125                panic!("MySQL connection without `mysql` feature compiled in")
126            }
127        }
128    }
129
130    /// Borrow the inner PostgreSQL client. Returns an error if this DbClient
131    /// is not a PostgreSQL connection — used as a transitional bridge for
132    /// command code that hasn't been ported to dialect-aware operation yet.
133    #[cfg(feature = "postgres")]
134    pub fn as_postgres(&self) -> Result<&Client> {
135        match self {
136            DbClient::Postgres(c) => Ok(c),
137            #[cfg(feature = "mysql")]
138            DbClient::Mysql(_) => Err(WaypointError::ConfigError(
139                "This operation is not yet implemented for MySQL".into(),
140            )),
141        }
142    }
143
144    /// Borrow the inner MySQL pool. Returns an error if this DbClient is not
145    /// a MySQL connection.
146    #[cfg(feature = "mysql")]
147    pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
148        match self {
149            DbClient::Mysql(p) => Ok(p),
150            #[cfg(feature = "postgres")]
151            DbClient::Postgres(_) => Err(WaypointError::ConfigError(
152                "This operation requires a MySQL connection".into(),
153            )),
154        }
155    }
156
157    /// Verify the database connection is still alive with a minimal round-trip.
158    pub async fn check_connection(&self) -> Result<()> {
159        match self {
160            #[cfg(feature = "postgres")]
161            DbClient::Postgres(c) => check_connection(c).await,
162            #[cfg(feature = "mysql")]
163            DbClient::Mysql(pool) => {
164                use mysql_async::prelude::*;
165                let mut conn =
166                    pool.get_conn()
167                        .await
168                        .map_err(|e| WaypointError::ConnectionLost {
169                            operation: "health check".into(),
170                            detail: e.to_string(),
171                        })?;
172                conn.query_drop("DO 0")
173                    .await
174                    .map_err(|e| WaypointError::ConnectionLost {
175                        operation: "health check".into(),
176                        detail: e.to_string(),
177                    })?;
178                Ok(())
179            }
180        }
181    }
182
183    /// Acquire a session-scoped advisory lock keyed by the history-table name.
184    ///
185    /// PostgreSQL: `pg_advisory_lock(<i64>)` derived from a CRC32 of the table name.
186    /// MySQL: `GET_LOCK('waypoint_<table>', -1)` (named, indefinite-wait).
187    pub async fn acquire_lock(&self, table_name: &str) -> Result<()> {
188        match self {
189            #[cfg(feature = "postgres")]
190            DbClient::Postgres(c) => acquire_advisory_lock(c, table_name).await,
191            #[cfg(feature = "mysql")]
192            DbClient::Mysql(pool) => {
193                use mysql_async::prelude::*;
194                let key = mysql_lock_key(table_name);
195                let mut conn = pool.get_conn().await?;
196                let acquired: Option<i64> = conn
197                    .exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
198                    .await?;
199                match acquired {
200                    Some(1) => {
201                        park_lock_conn(pool, &key, conn);
202                        Ok(())
203                    }
204                    _ => Err(WaypointError::LockError(format!(
205                        "Failed to acquire MySQL named lock {}",
206                        key
207                    ))),
208                }
209            }
210        }
211    }
212
213    /// Try to acquire the advisory lock, polling until acquired or timeout expires.
214    pub async fn acquire_lock_with_timeout(
215        &self,
216        table_name: &str,
217        timeout_secs: u32,
218    ) -> Result<()> {
219        match self {
220            #[cfg(feature = "postgres")]
221            DbClient::Postgres(c) => {
222                acquire_advisory_lock_with_timeout(c, table_name, timeout_secs).await
223            }
224            #[cfg(feature = "mysql")]
225            DbClient::Mysql(pool) => {
226                use mysql_async::prelude::*;
227                let key = mysql_lock_key(table_name);
228                let mut conn = pool.get_conn().await?;
229                let acquired: Option<i64> = conn
230                    .exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
231                    .await?;
232                match acquired {
233                    Some(1) => {
234                        park_lock_conn(pool, &key, conn);
235                        Ok(())
236                    }
237                    Some(0) => Err(WaypointError::LockError(format!(
238                        "Timed out waiting for MySQL named lock {} after {}s",
239                        key, timeout_secs
240                    ))),
241                    _ => Err(WaypointError::LockError(format!(
242                        "Failed to acquire MySQL named lock {} (NULL result)",
243                        key
244                    ))),
245                }
246            }
247        }
248    }
249
250    /// Release the advisory lock acquired via [`Self::acquire_lock`].
251    pub async fn release_lock(&self, table_name: &str) -> Result<()> {
252        match self {
253            #[cfg(feature = "postgres")]
254            DbClient::Postgres(c) => release_advisory_lock(c, table_name).await,
255            #[cfg(feature = "mysql")]
256            DbClient::Mysql(pool) => {
257                use mysql_async::prelude::*;
258                let key = mysql_lock_key(table_name);
259                // Release on the *same* session that acquired it. A different
260                // connection's RELEASE_LOCK is a silent no-op (returns 0) and
261                // would leak the lock until the server reaps the session.
262                let mut conn = match unpark_lock_conn(pool, &key) {
263                    Some(conn) => conn,
264                    None => {
265                        return Err(WaypointError::LockError(format!(
266                            "No pinned connection holds MySQL named lock {} — \
267                             release_lock called without a matching acquire_lock",
268                            key
269                        )));
270                    }
271                };
272                let released = conn
273                    .exec_first::<Option<i64>, _, _>("SELECT RELEASE_LOCK(?)", (key.clone(),))
274                    .await;
275                // Return the connection to the pool either way; dropping it
276                // here also drops the lock, so a failed RELEASE_LOCK is not
277                // fatal — the session reset on return clears it.
278                drop(conn);
279                match released {
280                    Ok(Some(Some(1))) => Ok(()),
281                    Ok(_) => {
282                        log::warn!(
283                            "RELEASE_LOCK({}) did not report success; the lock is released \
284                             regardless because the holding session was returned to the pool",
285                            key
286                        );
287                        Ok(())
288                    }
289                    Err(e) => Err(WaypointError::MysqlError(e)),
290                }
291            }
292        }
293    }
294
295    /// Get the current database user/account.
296    pub async fn current_user(&self) -> Result<String> {
297        match self {
298            #[cfg(feature = "postgres")]
299            DbClient::Postgres(c) => get_current_user(c).await,
300            #[cfg(feature = "mysql")]
301            DbClient::Mysql(pool) => {
302                use mysql_async::prelude::*;
303                let mut conn = pool.get_conn().await?;
304                let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
305                user.ok_or_else(|| {
306                    WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
307                })
308            }
309        }
310    }
311
312    /// Get the current database name.
313    pub async fn current_database(&self) -> Result<String> {
314        match self {
315            #[cfg(feature = "postgres")]
316            DbClient::Postgres(c) => get_current_database(c).await,
317            #[cfg(feature = "mysql")]
318            DbClient::Mysql(pool) => {
319                use mysql_async::prelude::*;
320                let mut conn = pool.get_conn().await?;
321                // DATABASE() returns NULL when no schema is selected on the connection
322                let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
323                match db.flatten() {
324                    Some(name) => Ok(name),
325                    None => Err(WaypointError::ConfigError(
326                        "MySQL connection has no current database (none selected in URL)".into(),
327                    )),
328                }
329            }
330        }
331    }
332
333    /// Resolve the schema/database name to use for the history table.
334    ///
335    /// On PostgreSQL the configured value is used as-is. On MySQL there is no
336    /// schema concept distinct from the database; if the configured value is
337    /// the PG-default `"public"`, we fall back to the connection's current
338    /// database so a PG-shaped config keeps working when pointed at MySQL.
339    pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
340        match self.dialect_kind() {
341            DialectKind::Postgres => Ok(configured.to_string()),
342            DialectKind::Mysql => {
343                if configured == "public" {
344                    self.current_database().await
345                } else {
346                    Ok(configured.to_string())
347                }
348            }
349        }
350    }
351
352    /// Run one or more `;`-separated SQL statements without an explicit transaction.
353    ///
354    /// On PostgreSQL this is a single `batch_execute` call. On MySQL it splits
355    /// the batch into individual statements via
356    /// [`crate::sql_parser::split_mysql_statements`] (mysql_async's underlying
357    /// protocol doesn't accept multiple statements unless the connection is
358    /// built with `CLIENT_MULTI_STATEMENTS`, which we deliberately avoid).
359    /// Returns elapsed time in milliseconds.
360    pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
361        match self {
362            #[cfg(feature = "postgres")]
363            DbClient::Postgres(c) => execute_raw(c, sql).await,
364            #[cfg(feature = "mysql")]
365            DbClient::Mysql(pool) => {
366                use mysql_async::prelude::*;
367                let start = std::time::Instant::now();
368                let mut conn = pool.get_conn().await?;
369                for stmt in crate::sql_parser::split_mysql_statements(sql) {
370                    conn.query_drop(&stmt).await?;
371                }
372                Ok(start.elapsed().as_millis() as i32)
373            }
374        }
375    }
376
377    /// Run SQL inside a transaction where the engine supports DDL rollback.
378    ///
379    /// On PostgreSQL this issues `BEGIN` / `COMMIT` (with `ROLLBACK` on failure)
380    /// around `batch_execute`. On MySQL most DDL implicitly commits, so a
381    /// transaction wrapper provides no rollback guarantee for DDL — we issue
382    /// the statements without a wrapper and surface failures as they arise.
383    /// Callers needing strict batch atomicity should consult
384    /// [`DatabaseDialect::supports_transactional_ddl`] before invoking.
385    pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
386        match self {
387            #[cfg(feature = "postgres")]
388            DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
389            #[cfg(feature = "mysql")]
390            DbClient::Mysql(_) => self.execute_raw(sql).await,
391        }
392    }
393}
394
395/// Connect to whichever backend the URL scheme indicates.
396///
397/// The single place that maps a connection string to a [`DbClient`]. Engine is
398/// taken from the URL scheme (`postgres://` / `postgresql://` → PostgreSQL,
399/// `mysql://` → MySQL); anything else — notably libpq `key=value` strings —
400/// falls back to `config.database.engine`, which defaults to PostgreSQL.
401///
402/// PostgreSQL connections pick up the full `[database]` transport config
403/// (SSL mode, retries, timeouts, keepalive).
404pub async fn connect_for_url(
405    conn_string: &str,
406    #[cfg_attr(not(feature = "postgres"), allow(unused_variables))]
407    config: &crate::config::WaypointConfig,
408) -> Result<DbClient> {
409    let kind = DialectKind::from_url(conn_string).unwrap_or(config.database.engine);
410    match kind {
411        #[cfg(feature = "postgres")]
412        DialectKind::Postgres => {
413            let client = connect_with_full_config(
414                conn_string,
415                &config.database.ssl_mode,
416                config.database.connect_retries,
417                config.database.connect_timeout_secs,
418                config.database.statement_timeout_secs,
419                config.database.keepalive_secs,
420            )
421            .await?;
422            Ok(DbClient::with_postgres(client))
423        }
424        #[cfg(not(feature = "postgres"))]
425        DialectKind::Postgres => Err(WaypointError::ConfigError(
426            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
427        )),
428        #[cfg(feature = "mysql")]
429        DialectKind::Mysql => {
430            let pool = mysql_async::Pool::from_url(conn_string).map_err(|e| {
431                WaypointError::ConfigError(format!("Invalid MySQL connection URL: {}", e))
432            })?;
433            Ok(DbClient::with_mysql(pool))
434        }
435        #[cfg(not(feature = "mysql"))]
436        DialectKind::Mysql => Err(WaypointError::ConfigError(
437            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
438        )),
439    }
440}
441
442/// Compute the MySQL named-lock key for a given history table name.
443///
444/// MySQL `GET_LOCK` keys are arbitrary strings (truncated to 64 chars in 8.0+).
445/// We prefix `waypoint_` to avoid clashes with application locks and keep the
446/// key stable across versions.
447#[cfg(feature = "mysql")]
448fn mysql_lock_key(table_name: &str) -> String {
449    let mut k = format!("waypoint_{}", table_name);
450    if k.len() > 64 {
451        k.truncate(64);
452    }
453    k
454}
455
456/// Registry of pinned connections that currently hold a MySQL named lock.
457///
458/// `GET_LOCK` is **session**-scoped, and `mysql_async`'s pool defaults to
459/// `reset_connection = true`, which issues `COM_RESET_CONNECTION` when a
460/// `Conn` is returned to the pool. `COM_RESET_CONNECTION` explicitly releases
461/// locks acquired with `GET_LOCK()`. So acquiring the lock on a borrowed
462/// connection and dropping it back into the pool releases the lock
463/// immediately — the migration lock would provide no exclusion at all.
464///
465/// We therefore keep the acquiring `Conn` checked *out* of the pool for the
466/// whole lock lifetime, parked here, and release the lock on that same
467/// connection. A second acquire in the same process cannot get this
468/// connection back (it is not in the pool), so it takes a fresh session and
469/// blocks on `GET_LOCK` exactly as a separate process would.
470///
471/// Keyed by server identity + lock name so that a mixed-engine or
472/// multi-database run targeting two MySQL servers with the same history-table
473/// name keeps its locks distinct.
474#[cfg(feature = "mysql")]
475type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
476
477#[cfg(feature = "mysql")]
478static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
479    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
480
481/// Identity of the pool a lock was taken on, for registry keying.
482///
483/// The address of the `Pool` inside its owning [`DbClient`] scopes the entry to
484/// that specific client instance, which is what we want: two `DbClient`s
485/// pointing at different MySQL servers must not share a registry slot even
486/// when they use the same history-table name.
487///
488/// `acquire_lock` / `release_lock` are always called through the same
489/// `&DbClient` borrow (acquire, do work, release), so the value provably
490/// cannot move in between and the address is stable across the pair. If a
491/// caller were to move the owning `DbClient` while holding a lock, the parked
492/// connection would be orphaned and the lock would persist until the server
493/// reaps the session — degraded, but never silently unlocked.
494#[cfg(feature = "mysql")]
495fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
496    pool as *const mysql_async::Pool as usize
497}
498
499/// Park the lock-holding connection in the registry.
500#[cfg(feature = "mysql")]
501fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
502    let registry_key = (mysql_pool_ident(pool), key.to_string());
503    match MYSQL_LOCK_CONNS.lock() {
504        Ok(mut guard) => {
505            guard.insert(registry_key, conn);
506        }
507        Err(poisoned) => {
508            // A panic elsewhere poisoned the registry. Recover rather than
509            // propagate: losing the parked connection would leak the lock
510            // until the server times the session out.
511            poisoned.into_inner().insert(registry_key, conn);
512        }
513    }
514}
515
516/// Reclaim the lock-holding connection from the registry, if present.
517#[cfg(feature = "mysql")]
518fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
519    let registry_key = (mysql_pool_ident(pool), key.to_string());
520    match MYSQL_LOCK_CONNS.lock() {
521        Ok(mut guard) => guard.remove(&registry_key),
522        Err(poisoned) => poisoned.into_inner().remove(&registry_key),
523    }
524}
525
526// ── PostgreSQL-specific connection helpers (legacy entry points) ──────────────
527
528/// Build a rustls ClientConfig using the Mozilla CA bundle and ring crypto provider.
529#[cfg(feature = "postgres")]
530fn make_rustls_config() -> rustls::ClientConfig {
531    let root_store =
532        rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
533    rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
534        rustls::crypto::ring::default_provider(),
535    ))
536    .with_safe_default_protocol_versions()
537    .unwrap()
538    .with_root_certificates(root_store)
539    .with_no_client_auth()
540}
541
542/// Check if a postgres error is a permanent authentication failure that should not be retried.
543#[cfg(feature = "postgres")]
544fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
545    if let Some(db_err) = e.as_db_error() {
546        let code = db_err.code().code();
547        // 28P01 = invalid_password, 28000 = invalid_authorization_specification
548        return code == "28P01" || code == "28000";
549    }
550    false
551}
552
553/// Inject TCP keepalive parameters into a connection string if not already present.
554///
555/// For URL-style strings (`postgres://...`), appends `?keepalives=1&keepalives_idle=N`
556/// (or `&` if `?` already exists). For key=value style, appends ` keepalives=1 keepalives_idle=N`.
557/// Returns the string unchanged if `keepalive_secs == 0` or keepalive params already exist.
558pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
559    if keepalive_secs == 0 {
560        return conn_string.to_string();
561    }
562    let lower = conn_string.to_lowercase();
563    if lower.contains("keepalives") {
564        return conn_string.to_string();
565    }
566    let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
567    if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
568        if conn_string.contains('?') {
569            format!("{}&{}", conn_string, params)
570        } else {
571            format!("{}?{}", conn_string, params)
572        }
573    } else {
574        // Key=value style
575        format!(
576            "{} keepalives=1 keepalives_idle={}",
577            conn_string, keepalive_secs
578        )
579    }
580}
581
582/// Spawn the background connection driver task.
583///
584/// Both TLS and non-TLS connections produce a future that resolves when the
585/// connection terminates.  This helper accepts any such future and runs it
586/// on the tokio runtime, logging errors.
587#[cfg(feature = "postgres")]
588fn spawn_connection_task<F>(connection: F)
589where
590    F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
591        + Send
592        + 'static,
593{
594    tokio::spawn(async move {
595        if let Err(e) = connection.await {
596            log::error!("Database connection error: {}", e);
597        }
598    });
599}
600
601/// Connect to the database using the provided connection string with TLS support.
602///
603/// Spawns the connection task on the tokio runtime.
604#[cfg(feature = "postgres")]
605async fn connect_once(
606    conn_string: &str,
607    ssl_mode: &SslMode,
608    connect_timeout_secs: u32,
609) -> std::result::Result<Client, tokio_postgres::Error> {
610    let connect_fut = async {
611        match ssl_mode {
612            SslMode::Disable => {
613                let (client, connection) =
614                    tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
615                spawn_connection_task(connection);
616                Ok(client)
617            }
618            SslMode::Require => {
619                let tls_config = make_rustls_config();
620                let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
621                let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
622                spawn_connection_task(connection);
623                Ok(client)
624            }
625            SslMode::Prefer => {
626                // Try TLS first, fall back to plaintext
627                let tls_config = make_rustls_config();
628                let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
629                match tokio_postgres::connect(conn_string, tls).await {
630                    Ok((client, connection)) => {
631                        spawn_connection_task(connection);
632                        Ok(client)
633                    }
634                    Err(_) => {
635                        log::debug!("TLS connection failed, falling back to plaintext");
636                        let (client, connection) =
637                            tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
638                        spawn_connection_task(connection);
639                        Ok(client)
640                    }
641                }
642            }
643        }
644    };
645
646    if connect_timeout_secs > 0 {
647        match tokio::time::timeout(
648            std::time::Duration::from_secs(connect_timeout_secs as u64),
649            connect_fut,
650        )
651        .await
652        {
653            Ok(result) => result,
654            Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
655        }
656    } else {
657        connect_fut.await
658    }
659}
660
661/// Connect to the database using the provided connection string.
662///
663/// Spawns the connection task on the tokio runtime.
664#[cfg(feature = "postgres")]
665pub async fn connect(conn_string: &str) -> Result<Client> {
666    connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
667}
668
669/// Connect to the database, retrying up to `retries` times with exponential backoff + jitter.
670///
671/// Each retry waits `min(2^attempt, 30) + rand(0..1000ms)` before the next attempt.
672/// Permanent errors (authentication failures) are not retried.
673#[cfg(feature = "postgres")]
674pub async fn connect_with_config(
675    conn_string: &str,
676    ssl_mode: &SslMode,
677    retries: u32,
678    connect_timeout_secs: u32,
679    statement_timeout_secs: u32,
680) -> Result<Client> {
681    connect_with_full_config(
682        conn_string,
683        ssl_mode,
684        retries,
685        connect_timeout_secs,
686        statement_timeout_secs,
687        120,
688    )
689    .await
690}
691
692/// Connect to the database with all configuration options including TCP keepalive.
693#[cfg(feature = "postgres")]
694pub async fn connect_with_full_config(
695    conn_string: &str,
696    ssl_mode: &SslMode,
697    retries: u32,
698    connect_timeout_secs: u32,
699    statement_timeout_secs: u32,
700    keepalive_secs: u32,
701) -> Result<Client> {
702    let conn_string = inject_keepalive(conn_string, keepalive_secs);
703    let mut last_err = None;
704
705    for attempt in 0..=retries {
706        if attempt > 0 {
707            let base_delay = std::cmp::min(1u64 << attempt, 30);
708            let jitter_ms = fastrand::u64(0..1000);
709            let delay = std::time::Duration::from_secs(base_delay)
710                + std::time::Duration::from_millis(jitter_ms);
711            log::info!(
712                "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
713                attempt + 1,
714                retries + 1,
715                delay.as_millis() as u64
716            );
717            tokio::time::sleep(delay).await;
718        }
719
720        match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
721            Ok(client) => {
722                if attempt > 0 {
723                    log::info!(
724                        "Connected successfully after retry; attempt={}, max_attempts={}",
725                        attempt + 1,
726                        retries + 1
727                    );
728                }
729
730                // Set statement timeout if configured
731                if statement_timeout_secs > 0 {
732                    let timeout_sql =
733                        format!("SET statement_timeout = '{}s'", statement_timeout_secs);
734                    client.batch_execute(&timeout_sql).await?;
735                }
736
737                return Ok(client);
738            }
739            Err(e) => {
740                // Don't retry permanent errors (e.g. bad credentials)
741                if is_permanent_error(&e) {
742                    log::error!("Permanent connection error, not retrying: {}", e);
743                    return Err(WaypointError::DatabaseError(e));
744                }
745                last_err = Some(e);
746            }
747        }
748    }
749
750    Err(WaypointError::DatabaseError(last_err.unwrap()))
751}
752
753/// Acquire a PostgreSQL advisory lock based on the history table name.
754///
755/// This prevents concurrent migration runs from interfering with each other.
756#[cfg(feature = "postgres")]
757pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
758    let lock_id = advisory_lock_id(table_name);
759    log::info!(
760        "Acquiring advisory lock; lock_id={}, table={}",
761        lock_id,
762        table_name
763    );
764
765    client
766        .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
767        .await
768        .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
769
770    Ok(())
771}
772
773/// Try to acquire a PostgreSQL advisory lock with a timeout.
774///
775/// Uses `pg_try_advisory_lock()` in a polling loop with configurable timeout.
776/// Returns Ok(()) if lock acquired, or a LockError if the timeout expires.
777#[cfg(feature = "postgres")]
778pub async fn acquire_advisory_lock_with_timeout(
779    client: &Client,
780    table_name: &str,
781    timeout_secs: u32,
782) -> Result<()> {
783    let lock_id = advisory_lock_id(table_name);
784    log::info!(
785        "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
786        lock_id,
787        table_name,
788        timeout_secs
789    );
790
791    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
792
793    loop {
794        let row = client
795            .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
796            .await
797            .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
798
799        let acquired: bool = row.get(0);
800        if acquired {
801            return Ok(());
802        }
803
804        if std::time::Instant::now() >= deadline {
805            return Err(WaypointError::LockError(format!(
806                "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
807                timeout_secs, table_name
808            )));
809        }
810
811        // Wait 500ms before retrying
812        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
813    }
814}
815
816/// Release the PostgreSQL advisory lock.
817#[cfg(feature = "postgres")]
818pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
819    let lock_id = advisory_lock_id(table_name);
820    log::info!(
821        "Releasing advisory lock; lock_id={}, table={}",
822        lock_id,
823        table_name
824    );
825
826    client
827        .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
828        .await
829        .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
830
831    Ok(())
832}
833
834/// Compute a stable i64 lock ID from the table name using CRC32.
835///
836/// Uses CRC32 instead of DefaultHasher for cross-version stability —
837/// DefaultHasher is not guaranteed to produce the same output across
838/// Rust compiler versions.
839pub fn advisory_lock_id(table_name: &str) -> i64 {
840    crc32fast::hash(table_name.as_bytes()) as i64
841}
842
843/// Get the current database user.
844#[cfg(feature = "postgres")]
845pub async fn get_current_user(client: &Client) -> Result<String> {
846    let row = client.query_one("SELECT current_user", &[]).await?;
847    Ok(row.get::<_, String>(0))
848}
849
850/// Get the current database name.
851#[cfg(feature = "postgres")]
852pub async fn get_current_database(client: &Client) -> Result<String> {
853    let row = client.query_one("SELECT current_database()", &[]).await?;
854    Ok(row.get::<_, String>(0))
855}
856
857/// Execute a SQL string within a transaction using SQL-level BEGIN/COMMIT.
858/// Returns the execution time in milliseconds.
859#[cfg(feature = "postgres")]
860pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
861    let start = std::time::Instant::now();
862
863    client.batch_execute("BEGIN").await?;
864
865    match client.batch_execute(sql).await {
866        Ok(()) => {
867            client.batch_execute("COMMIT").await?;
868        }
869        Err(e) => {
870            if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
871                log::warn!("Failed to rollback transaction: {}", rollback_err);
872            }
873            return Err(WaypointError::DatabaseError(e));
874        }
875    }
876
877    let elapsed = start.elapsed().as_millis() as i32;
878    Ok(elapsed)
879}
880
881/// Execute SQL without a transaction wrapper (for statements that can't run in a transaction).
882#[cfg(feature = "postgres")]
883pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
884    let start = std::time::Instant::now();
885    client.batch_execute(sql).await?;
886    let elapsed = start.elapsed().as_millis() as i32;
887    Ok(elapsed)
888}
889
890/// Check if an error is a transient connection error that may be retried.
891///
892/// Detects PostgreSQL server shutdown codes, connection exception codes,
893/// closed connections, and common network error message patterns.
894pub fn is_transient_error(e: &WaypointError) -> bool {
895    match e {
896        #[cfg(feature = "postgres")]
897        WaypointError::DatabaseError(pg_err) => {
898            // Check if the connection is closed
899            if pg_err.is_closed() {
900                return true;
901            }
902            // Check PostgreSQL error codes
903            if let Some(db_err) = pg_err.as_db_error() {
904                let code = db_err.code().code();
905                // 57P01 = admin_shutdown, 57P02 = crash_shutdown, 57P03 = cannot_connect_now
906                // 08000 = connection_exception, 08003 = connection_does_not_exist,
907                // 08006 = connection_failure
908                return matches!(
909                    code,
910                    "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
911                );
912            }
913            // Check error message patterns for connection-related issues
914            let msg = pg_err.to_string().to_lowercase();
915            msg.contains("connection reset")
916                || msg.contains("broken pipe")
917                || msg.contains("connection closed")
918                || msg.contains("unexpected eof")
919        }
920        #[cfg(feature = "mysql")]
921        WaypointError::MysqlError(my_err) => {
922            // mysql_async surfaces server-shutdown / connection-reset as IO or
923            // driver errors. Do a coarse string match for now; we'll refine when
924            // we wire production retry logic for MySQL in Phase 1.
925            let msg = my_err.to_string().to_lowercase();
926            msg.contains("connection reset")
927                || msg.contains("broken pipe")
928                || msg.contains("connection closed")
929                || msg.contains("server has gone away")
930                || msg.contains("lost connection")
931                || msg.contains("io error")
932        }
933        WaypointError::ConnectionLost { .. } => true,
934        _ => false,
935    }
936}
937
938/// Verify the database connection is still alive with a minimal round-trip.
939#[cfg(feature = "postgres")]
940pub async fn check_connection(client: &Client) -> Result<()> {
941    client
942        .simple_query("")
943        .await
944        .map_err(|e| WaypointError::ConnectionLost {
945            operation: "health check".to_string(),
946            detail: e.to_string(),
947        })?;
948    Ok(())
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    // ── inject_keepalive tests ──
956
957    #[test]
958    fn test_inject_keepalive_url_style() {
959        let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
960        assert_eq!(
961            result,
962            "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
963        );
964    }
965
966    #[test]
967    fn test_inject_keepalive_url_with_existing_params() {
968        let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
969        assert_eq!(
970            result,
971            "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
972        );
973    }
974
975    #[test]
976    fn test_inject_keepalive_kv_style() {
977        let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
978        assert_eq!(
979            result,
980            "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
981        );
982    }
983
984    #[test]
985    fn test_inject_keepalive_zero_disables() {
986        let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
987        assert_eq!(result, "postgres://user:pass@localhost/db");
988    }
989
990    #[test]
991    fn test_inject_keepalive_already_present() {
992        let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
993        assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
994    }
995
996    // ── is_transient_error tests ──
997
998    #[test]
999    fn test_transient_error_connection_lost() {
1000        let err = WaypointError::ConnectionLost {
1001            operation: "test".to_string(),
1002            detail: "gone".to_string(),
1003        };
1004        assert!(is_transient_error(&err));
1005    }
1006
1007    #[test]
1008    fn test_transient_error_config_is_not_transient() {
1009        let err = WaypointError::ConfigError("bad config".to_string());
1010        assert!(!is_transient_error(&err));
1011    }
1012
1013    #[test]
1014    fn test_transient_error_migration_failed_is_not_transient() {
1015        let err = WaypointError::MigrationFailed {
1016            script: "V1__test.sql".to_string(),
1017            reason: "syntax error".to_string(),
1018        };
1019        assert!(!is_transient_error(&err));
1020    }
1021
1022    #[test]
1023    fn test_advisory_lock_id_stability() {
1024        // Ensure the same table name always produces the same lock ID
1025        let id1 = advisory_lock_id("waypoint_schema_history");
1026        let id2 = advisory_lock_id("waypoint_schema_history");
1027        assert_eq!(id1, id2);
1028        // Different table names should produce different lock IDs
1029        let id3 = advisory_lock_id("other_table");
1030        assert_ne!(id1, id3);
1031    }
1032
1033    #[test]
1034    fn test_transient_error_lock_error_is_not_transient() {
1035        let err = WaypointError::LockError("lock failed".to_string());
1036        assert!(!is_transient_error(&err));
1037    }
1038
1039    #[test]
1040    fn test_transient_error_io_error_is_not_transient() {
1041        let err = WaypointError::IoError(std::io::Error::new(
1042            std::io::ErrorKind::NotFound,
1043            "file not found",
1044        ));
1045        assert!(!is_transient_error(&err));
1046    }
1047
1048    #[test]
1049    fn test_validate_identifier_valid() {
1050        assert!(validate_identifier("users").is_ok());
1051        assert!(validate_identifier("my_table").is_ok());
1052        assert!(validate_identifier("Table123").is_ok());
1053        assert!(validate_identifier("a").is_ok());
1054    }
1055
1056    #[test]
1057    fn test_validate_identifier_invalid() {
1058        assert!(validate_identifier("").is_err());
1059        assert!(validate_identifier("my-table").is_err());
1060        assert!(validate_identifier("my table").is_err());
1061        assert!(validate_identifier("table.name").is_err());
1062        assert!(validate_identifier("table;drop").is_err());
1063    }
1064
1065    #[test]
1066    fn test_quote_ident_simple() {
1067        assert_eq!(quote_ident("users"), "\"users\"");
1068    }
1069
1070    #[test]
1071    fn test_quote_ident_embedded_quotes() {
1072        assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1073    }
1074
1075    #[test]
1076    fn test_quote_ident_empty() {
1077        assert_eq!(quote_ident(""), "\"\"");
1078    }
1079
1080    #[test]
1081    fn test_inject_keepalive_postgresql_prefix() {
1082        let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1083        assert_eq!(
1084            result,
1085            "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1086        );
1087    }
1088}