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