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(&mysql_lock_scope(self).await, 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(&mysql_lock_scope(self).await, 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(&mysql_lock_scope(self).await, 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 history table in a given database.
443///
444/// # Scoping
445///
446/// MySQL `GET_LOCK` names live in a **server-global** namespace, unlike
447/// PostgreSQL advisory locks which are scoped to the current database. Keying
448/// on the table name alone therefore made every database on a shared MySQL
449/// server contend for one lock: migrating `app_staging` blocked a concurrent
450/// migration of `app_prod`, even though they share nothing. Including the
451/// database name restores per-database scoping and matches the PostgreSQL
452/// behaviour.
453///
454/// # Length
455///
456/// `GET_LOCK` names are capped at 64 characters on MySQL 8.0+. Plain
457/// truncation would let two distinct long `db.table` pairs collapse onto one
458/// key — silently over-serialising, or worse, letting a caller release a lock
459/// it does not hold. Over-long keys fall back to a CRC32 of the full name,
460/// which is stable across versions and platforms.
461#[cfg(feature = "mysql")]
462fn mysql_lock_key(schema: &str, table_name: &str) -> String {
463    let full = format!("waypoint_{}_{}", schema, table_name);
464    if full.len() <= 64 {
465        full
466    } else {
467        format!("waypoint_{:08x}", crc32fast::hash(full.as_bytes()))
468    }
469}
470
471/// The database name to scope a MySQL lock to.
472///
473/// Falls back to a fixed marker when the connection has no default database;
474/// a lock still has to be taken, and a shared key is safe (it only
475/// over-serialises), whereas skipping the lock would not be.
476#[cfg(feature = "mysql")]
477async fn mysql_lock_scope(client: &DbClient) -> String {
478    client
479        .current_database()
480        .await
481        .unwrap_or_else(|_| "_nodb".to_string())
482}
483
484/// Registry of pinned connections that currently hold a MySQL named lock.
485///
486/// `GET_LOCK` is **session**-scoped, and `mysql_async`'s pool defaults to
487/// `reset_connection = true`, which issues `COM_RESET_CONNECTION` when a
488/// `Conn` is returned to the pool. `COM_RESET_CONNECTION` explicitly releases
489/// locks acquired with `GET_LOCK()`. So acquiring the lock on a borrowed
490/// connection and dropping it back into the pool releases the lock
491/// immediately — the migration lock would provide no exclusion at all.
492///
493/// We therefore keep the acquiring `Conn` checked *out* of the pool for the
494/// whole lock lifetime, parked here, and release the lock on that same
495/// connection. A second acquire in the same process cannot get this
496/// connection back (it is not in the pool), so it takes a fresh session and
497/// blocks on `GET_LOCK` exactly as a separate process would.
498///
499/// Keyed by server identity + lock name so that a mixed-engine or
500/// multi-database run targeting two MySQL servers with the same history-table
501/// name keeps its locks distinct.
502#[cfg(feature = "mysql")]
503type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
504
505#[cfg(feature = "mysql")]
506static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
507    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
508
509/// Identity of the pool a lock was taken on, for registry keying.
510///
511/// The address of the `Pool` inside its owning [`DbClient`] scopes the entry to
512/// that specific client instance, which is what we want: two `DbClient`s
513/// pointing at different MySQL servers must not share a registry slot even
514/// when they use the same history-table name.
515///
516/// `acquire_lock` / `release_lock` are always called through the same
517/// `&DbClient` borrow (acquire, do work, release), so the value provably
518/// cannot move in between and the address is stable across the pair. If a
519/// caller were to move the owning `DbClient` while holding a lock, the parked
520/// connection would be orphaned and the lock would persist until the server
521/// reaps the session — degraded, but never silently unlocked.
522#[cfg(feature = "mysql")]
523fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
524    pool as *const mysql_async::Pool as usize
525}
526
527/// Park the lock-holding connection in the registry.
528#[cfg(feature = "mysql")]
529fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
530    let registry_key = (mysql_pool_ident(pool), key.to_string());
531    match MYSQL_LOCK_CONNS.lock() {
532        Ok(mut guard) => {
533            guard.insert(registry_key, conn);
534        }
535        Err(poisoned) => {
536            // A panic elsewhere poisoned the registry. Recover rather than
537            // propagate: losing the parked connection would leak the lock
538            // until the server times the session out.
539            poisoned.into_inner().insert(registry_key, conn);
540        }
541    }
542}
543
544/// Reclaim the lock-holding connection from the registry, if present.
545#[cfg(feature = "mysql")]
546fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
547    let registry_key = (mysql_pool_ident(pool), key.to_string());
548    match MYSQL_LOCK_CONNS.lock() {
549        Ok(mut guard) => guard.remove(&registry_key),
550        Err(poisoned) => poisoned.into_inner().remove(&registry_key),
551    }
552}
553
554// ── PostgreSQL-specific connection helpers (legacy entry points) ──────────────
555
556/// Build a rustls ClientConfig using the Mozilla CA bundle and ring crypto provider.
557#[cfg(feature = "postgres")]
558fn make_rustls_config() -> rustls::ClientConfig {
559    let root_store =
560        rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
561    rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
562        rustls::crypto::ring::default_provider(),
563    ))
564    .with_safe_default_protocol_versions()
565    .unwrap()
566    .with_root_certificates(root_store)
567    .with_no_client_auth()
568}
569
570/// Check if a postgres error is a permanent authentication failure that should not be retried.
571#[cfg(feature = "postgres")]
572fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
573    if let Some(db_err) = e.as_db_error() {
574        let code = db_err.code().code();
575        // 28P01 = invalid_password, 28000 = invalid_authorization_specification
576        return code == "28P01" || code == "28000";
577    }
578    false
579}
580
581/// Inject TCP keepalive parameters into a connection string if not already present.
582///
583/// For URL-style strings (`postgres://...`), appends `?keepalives=1&keepalives_idle=N`
584/// (or `&` if `?` already exists). For key=value style, appends ` keepalives=1 keepalives_idle=N`.
585/// Returns the string unchanged if `keepalive_secs == 0` or keepalive params already exist.
586pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
587    if keepalive_secs == 0 {
588        return conn_string.to_string();
589    }
590    let lower = conn_string.to_lowercase();
591    if lower.contains("keepalives") {
592        return conn_string.to_string();
593    }
594    let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
595    if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
596        if conn_string.contains('?') {
597            format!("{}&{}", conn_string, params)
598        } else {
599            format!("{}?{}", conn_string, params)
600        }
601    } else {
602        // Key=value style
603        format!(
604            "{} keepalives=1 keepalives_idle={}",
605            conn_string, keepalive_secs
606        )
607    }
608}
609
610/// Spawn the background connection driver task.
611///
612/// Both TLS and non-TLS connections produce a future that resolves when the
613/// connection terminates.  This helper accepts any such future and runs it
614/// on the tokio runtime, logging errors.
615#[cfg(feature = "postgres")]
616fn spawn_connection_task<F>(connection: F)
617where
618    F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
619        + Send
620        + 'static,
621{
622    tokio::spawn(async move {
623        if let Err(e) = connection.await {
624            log::error!("Database connection error: {}", e);
625        }
626    });
627}
628
629/// Connect to the database using the provided connection string with TLS support.
630///
631/// Spawns the connection task on the tokio runtime.
632#[cfg(feature = "postgres")]
633async fn connect_once(
634    conn_string: &str,
635    ssl_mode: &SslMode,
636    connect_timeout_secs: u32,
637) -> std::result::Result<Client, tokio_postgres::Error> {
638    let connect_fut = async {
639        match ssl_mode {
640            SslMode::Disable => {
641                let (client, connection) =
642                    tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
643                spawn_connection_task(connection);
644                Ok(client)
645            }
646            SslMode::Require => {
647                let tls_config = make_rustls_config();
648                let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
649                let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
650                spawn_connection_task(connection);
651                Ok(client)
652            }
653            SslMode::Prefer => {
654                // Try TLS first, fall back to plaintext
655                let tls_config = make_rustls_config();
656                let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
657                match tokio_postgres::connect(conn_string, tls).await {
658                    Ok((client, connection)) => {
659                        spawn_connection_task(connection);
660                        Ok(client)
661                    }
662                    Err(_) => {
663                        log::debug!("TLS connection failed, falling back to plaintext");
664                        let (client, connection) =
665                            tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
666                        spawn_connection_task(connection);
667                        Ok(client)
668                    }
669                }
670            }
671        }
672    };
673
674    if connect_timeout_secs > 0 {
675        match tokio::time::timeout(
676            std::time::Duration::from_secs(connect_timeout_secs as u64),
677            connect_fut,
678        )
679        .await
680        {
681            Ok(result) => result,
682            Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
683        }
684    } else {
685        connect_fut.await
686    }
687}
688
689/// Connect to the database using the provided connection string.
690///
691/// Spawns the connection task on the tokio runtime.
692#[cfg(feature = "postgres")]
693pub async fn connect(conn_string: &str) -> Result<Client> {
694    connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
695}
696
697/// Connect to the database, retrying up to `retries` times with exponential backoff + jitter.
698///
699/// Each retry waits `min(2^attempt, 30) + rand(0..1000ms)` before the next attempt.
700/// Permanent errors (authentication failures) are not retried.
701#[cfg(feature = "postgres")]
702pub async fn connect_with_config(
703    conn_string: &str,
704    ssl_mode: &SslMode,
705    retries: u32,
706    connect_timeout_secs: u32,
707    statement_timeout_secs: u32,
708) -> Result<Client> {
709    connect_with_full_config(
710        conn_string,
711        ssl_mode,
712        retries,
713        connect_timeout_secs,
714        statement_timeout_secs,
715        120,
716    )
717    .await
718}
719
720/// Connect to the database with all configuration options including TCP keepalive.
721#[cfg(feature = "postgres")]
722pub async fn connect_with_full_config(
723    conn_string: &str,
724    ssl_mode: &SslMode,
725    retries: u32,
726    connect_timeout_secs: u32,
727    statement_timeout_secs: u32,
728    keepalive_secs: u32,
729) -> Result<Client> {
730    let conn_string = inject_keepalive(conn_string, keepalive_secs);
731    let mut last_err = None;
732
733    for attempt in 0..=retries {
734        if attempt > 0 {
735            let base_delay = std::cmp::min(1u64 << attempt, 30);
736            let jitter_ms = fastrand::u64(0..1000);
737            let delay = std::time::Duration::from_secs(base_delay)
738                + std::time::Duration::from_millis(jitter_ms);
739            log::info!(
740                "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
741                attempt + 1,
742                retries + 1,
743                delay.as_millis() as u64
744            );
745            tokio::time::sleep(delay).await;
746        }
747
748        match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
749            Ok(client) => {
750                if attempt > 0 {
751                    log::info!(
752                        "Connected successfully after retry; attempt={}, max_attempts={}",
753                        attempt + 1,
754                        retries + 1
755                    );
756                }
757
758                // Set statement timeout if configured
759                if statement_timeout_secs > 0 {
760                    let timeout_sql =
761                        format!("SET statement_timeout = '{}s'", statement_timeout_secs);
762                    client.batch_execute(&timeout_sql).await?;
763                }
764
765                return Ok(client);
766            }
767            Err(e) => {
768                // Don't retry permanent errors (e.g. bad credentials)
769                if is_permanent_error(&e) {
770                    log::error!("Permanent connection error, not retrying: {}", e);
771                    return Err(WaypointError::DatabaseError(e));
772                }
773                last_err = Some(e);
774            }
775        }
776    }
777
778    Err(WaypointError::DatabaseError(last_err.unwrap()))
779}
780
781/// Acquire a PostgreSQL advisory lock based on the history table name.
782///
783/// This prevents concurrent migration runs from interfering with each other.
784#[cfg(feature = "postgres")]
785pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
786    let lock_id = advisory_lock_id(table_name);
787    log::info!(
788        "Acquiring advisory lock; lock_id={}, table={}",
789        lock_id,
790        table_name
791    );
792
793    client
794        .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
795        .await
796        .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
797
798    Ok(())
799}
800
801/// Try to acquire a PostgreSQL advisory lock with a timeout.
802///
803/// Uses `pg_try_advisory_lock()` in a polling loop with configurable timeout.
804/// Returns Ok(()) if lock acquired, or a LockError if the timeout expires.
805#[cfg(feature = "postgres")]
806pub async fn acquire_advisory_lock_with_timeout(
807    client: &Client,
808    table_name: &str,
809    timeout_secs: u32,
810) -> Result<()> {
811    let lock_id = advisory_lock_id(table_name);
812    log::info!(
813        "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
814        lock_id,
815        table_name,
816        timeout_secs
817    );
818
819    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
820
821    loop {
822        let row = client
823            .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
824            .await
825            .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
826
827        let acquired: bool = row.get(0);
828        if acquired {
829            return Ok(());
830        }
831
832        if std::time::Instant::now() >= deadline {
833            return Err(WaypointError::LockError(format!(
834                "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
835                timeout_secs, table_name
836            )));
837        }
838
839        // Wait 500ms before retrying
840        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
841    }
842}
843
844/// Release the PostgreSQL advisory lock.
845#[cfg(feature = "postgres")]
846pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
847    let lock_id = advisory_lock_id(table_name);
848    log::info!(
849        "Releasing advisory lock; lock_id={}, table={}",
850        lock_id,
851        table_name
852    );
853
854    client
855        .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
856        .await
857        .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
858
859    Ok(())
860}
861
862/// Compute a stable i64 lock ID from the table name using CRC32.
863///
864/// Uses CRC32 instead of DefaultHasher for cross-version stability —
865/// DefaultHasher is not guaranteed to produce the same output across
866/// Rust compiler versions.
867pub fn advisory_lock_id(table_name: &str) -> i64 {
868    crc32fast::hash(table_name.as_bytes()) as i64
869}
870
871/// Get the current database user.
872#[cfg(feature = "postgres")]
873pub async fn get_current_user(client: &Client) -> Result<String> {
874    let row = client.query_one("SELECT current_user", &[]).await?;
875    Ok(row.get::<_, String>(0))
876}
877
878/// Get the current database name.
879#[cfg(feature = "postgres")]
880pub async fn get_current_database(client: &Client) -> Result<String> {
881    let row = client.query_one("SELECT current_database()", &[]).await?;
882    Ok(row.get::<_, String>(0))
883}
884
885/// Execute a SQL string within a transaction using SQL-level BEGIN/COMMIT.
886/// Returns the execution time in milliseconds.
887#[cfg(feature = "postgres")]
888pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
889    let start = std::time::Instant::now();
890
891    client.batch_execute("BEGIN").await?;
892
893    match client.batch_execute(sql).await {
894        Ok(()) => {
895            client.batch_execute("COMMIT").await?;
896        }
897        Err(e) => {
898            if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
899                log::warn!("Failed to rollback transaction: {}", rollback_err);
900            }
901            return Err(WaypointError::DatabaseError(e));
902        }
903    }
904
905    let elapsed = start.elapsed().as_millis() as i32;
906    Ok(elapsed)
907}
908
909/// Execute SQL without a transaction wrapper (for statements that can't run in a transaction).
910#[cfg(feature = "postgres")]
911pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
912    let start = std::time::Instant::now();
913    client.batch_execute(sql).await?;
914    let elapsed = start.elapsed().as_millis() as i32;
915    Ok(elapsed)
916}
917
918/// Check if an error is a transient connection error that may be retried.
919///
920/// Detects PostgreSQL server shutdown codes, connection exception codes,
921/// closed connections, and common network error message patterns.
922pub fn is_transient_error(e: &WaypointError) -> bool {
923    match e {
924        #[cfg(feature = "postgres")]
925        WaypointError::DatabaseError(pg_err) => {
926            // Check if the connection is closed
927            if pg_err.is_closed() {
928                return true;
929            }
930            // Check PostgreSQL error codes
931            if let Some(db_err) = pg_err.as_db_error() {
932                let code = db_err.code().code();
933                // 57P01 = admin_shutdown, 57P02 = crash_shutdown, 57P03 = cannot_connect_now
934                // 08000 = connection_exception, 08003 = connection_does_not_exist,
935                // 08006 = connection_failure
936                return matches!(
937                    code,
938                    "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
939                );
940            }
941            // Check error message patterns for connection-related issues
942            let msg = pg_err.to_string().to_lowercase();
943            msg.contains("connection reset")
944                || msg.contains("broken pipe")
945                || msg.contains("connection closed")
946                || msg.contains("unexpected eof")
947        }
948        #[cfg(feature = "mysql")]
949        WaypointError::MysqlError(my_err) => {
950            // mysql_async surfaces server-shutdown / connection-reset as IO or
951            // driver errors. Do a coarse string match for now; we'll refine when
952            // we wire production retry logic for MySQL in Phase 1.
953            let msg = my_err.to_string().to_lowercase();
954            msg.contains("connection reset")
955                || msg.contains("broken pipe")
956                || msg.contains("connection closed")
957                || msg.contains("server has gone away")
958                || msg.contains("lost connection")
959                || msg.contains("io error")
960        }
961        WaypointError::ConnectionLost { .. } => true,
962        _ => false,
963    }
964}
965
966/// Verify the database connection is still alive with a minimal round-trip.
967#[cfg(feature = "postgres")]
968pub async fn check_connection(client: &Client) -> Result<()> {
969    client
970        .simple_query("")
971        .await
972        .map_err(|e| WaypointError::ConnectionLost {
973            operation: "health check".to_string(),
974            detail: e.to_string(),
975        })?;
976    Ok(())
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    // ── inject_keepalive tests ──
984
985    #[test]
986    fn test_inject_keepalive_url_style() {
987        let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
988        assert_eq!(
989            result,
990            "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
991        );
992    }
993
994    #[test]
995    fn test_inject_keepalive_url_with_existing_params() {
996        let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
997        assert_eq!(
998            result,
999            "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
1000        );
1001    }
1002
1003    #[test]
1004    fn test_inject_keepalive_kv_style() {
1005        let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
1006        assert_eq!(
1007            result,
1008            "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
1009        );
1010    }
1011
1012    #[test]
1013    fn test_inject_keepalive_zero_disables() {
1014        let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
1015        assert_eq!(result, "postgres://user:pass@localhost/db");
1016    }
1017
1018    #[test]
1019    fn test_inject_keepalive_already_present() {
1020        let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
1021        assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
1022    }
1023
1024    // ── is_transient_error tests ──
1025
1026    #[test]
1027    fn test_transient_error_connection_lost() {
1028        let err = WaypointError::ConnectionLost {
1029            operation: "test".to_string(),
1030            detail: "gone".to_string(),
1031        };
1032        assert!(is_transient_error(&err));
1033    }
1034
1035    #[test]
1036    fn test_transient_error_config_is_not_transient() {
1037        let err = WaypointError::ConfigError("bad config".to_string());
1038        assert!(!is_transient_error(&err));
1039    }
1040
1041    #[test]
1042    fn test_transient_error_migration_failed_is_not_transient() {
1043        let err = WaypointError::MigrationFailed {
1044            script: "V1__test.sql".to_string(),
1045            reason: "syntax error".to_string(),
1046        };
1047        assert!(!is_transient_error(&err));
1048    }
1049
1050    #[test]
1051    fn test_advisory_lock_id_stability() {
1052        // Ensure the same table name always produces the same lock ID
1053        let id1 = advisory_lock_id("waypoint_schema_history");
1054        let id2 = advisory_lock_id("waypoint_schema_history");
1055        assert_eq!(id1, id2);
1056        // Different table names should produce different lock IDs
1057        let id3 = advisory_lock_id("other_table");
1058        assert_ne!(id1, id3);
1059    }
1060
1061    #[test]
1062    fn test_transient_error_lock_error_is_not_transient() {
1063        let err = WaypointError::LockError("lock failed".to_string());
1064        assert!(!is_transient_error(&err));
1065    }
1066
1067    #[test]
1068    fn test_transient_error_io_error_is_not_transient() {
1069        let err = WaypointError::IoError(std::io::Error::new(
1070            std::io::ErrorKind::NotFound,
1071            "file not found",
1072        ));
1073        assert!(!is_transient_error(&err));
1074    }
1075
1076    #[test]
1077    fn test_validate_identifier_valid() {
1078        assert!(validate_identifier("users").is_ok());
1079        assert!(validate_identifier("my_table").is_ok());
1080        assert!(validate_identifier("Table123").is_ok());
1081        assert!(validate_identifier("a").is_ok());
1082    }
1083
1084    #[test]
1085    fn test_validate_identifier_invalid() {
1086        assert!(validate_identifier("").is_err());
1087        assert!(validate_identifier("my-table").is_err());
1088        assert!(validate_identifier("my table").is_err());
1089        assert!(validate_identifier("table.name").is_err());
1090        assert!(validate_identifier("table;drop").is_err());
1091    }
1092
1093    #[test]
1094    fn test_quote_ident_simple() {
1095        assert_eq!(quote_ident("users"), "\"users\"");
1096    }
1097
1098    #[test]
1099    fn test_quote_ident_embedded_quotes() {
1100        assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1101    }
1102
1103    #[test]
1104    fn test_quote_ident_empty() {
1105        assert_eq!(quote_ident(""), "\"\"");
1106    }
1107
1108    #[test]
1109    fn test_inject_keepalive_postgresql_prefix() {
1110        let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1111        assert_eq!(
1112            result,
1113            "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1114        );
1115    }
1116
1117    #[cfg(feature = "mysql")]
1118    #[test]
1119    fn mysql_lock_key_is_scoped_per_database() {
1120        // GET_LOCK names are server-global, so the same history table in two
1121        // databases must not collide — otherwise migrating one database blocks
1122        // migrating the other.
1123        let a = mysql_lock_key("app_prod", "waypoint_schema_history");
1124        let b = mysql_lock_key("app_staging", "waypoint_schema_history");
1125        assert_ne!(a, b);
1126        assert_eq!(a, "waypoint_app_prod_waypoint_schema_history");
1127    }
1128
1129    #[cfg(feature = "mysql")]
1130    #[test]
1131    fn mysql_lock_key_respects_the_64_char_limit() {
1132        let long_db = "d".repeat(60);
1133        let long_tbl = "t".repeat(60);
1134        let k = mysql_lock_key(&long_db, &long_tbl);
1135        assert!(
1136            k.len() <= 64,
1137            "GET_LOCK names are capped at 64: {}",
1138            k.len()
1139        );
1140    }
1141
1142    #[cfg(feature = "mysql")]
1143    #[test]
1144    fn mysql_lock_key_does_not_collide_after_shortening() {
1145        // Two distinct over-long names must not fold onto the same key —
1146        // plain truncation would have made these identical.
1147        let prefix = "x".repeat(60);
1148        let a = mysql_lock_key(&prefix, "alpha");
1149        let b = mysql_lock_key(&prefix, "beta");
1150        assert!(a.len() <= 64 && b.len() <= 64);
1151        assert_ne!(a, b, "distinct tables collapsed onto one lock key");
1152    }
1153
1154    #[cfg(feature = "mysql")]
1155    #[test]
1156    fn mysql_lock_key_is_stable() {
1157        // The key has to be reproducible across processes and releases, or a
1158        // release_lock would not match its acquire_lock.
1159        assert_eq!(mysql_lock_key("db", "tbl"), mysql_lock_key("db", "tbl"));
1160    }
1161}