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::config::SslMode;
9use crate::dialect::{DatabaseDialect, DialectKind};
10use crate::error::{Result, WaypointError};
11use std::path::PathBuf;
12
13#[cfg(feature = "postgres")]
14use fastrand;
15
16#[cfg(feature = "postgres")]
17use tokio_postgres::Client;
18
19/// Transport-level connection settings: how to reach the server and how much
20/// to trust it.
21///
22/// Introduced to stop the connect helpers growing another positional argument
23/// — [`connect_with_full_config`] already took six, and TLS trust needs two
24/// more. Build one with [`TransportConfig::from_database_config`].
25#[derive(Debug, Clone)]
26pub struct TransportConfig {
27    /// TLS mode; see [`SslMode`] for the ladder.
28    pub ssl_mode: SslMode,
29    /// PEM file of CA certificates that replaces the built-in trust store.
30    pub ssl_root_cert: Option<PathBuf>,
31    /// Connection attempts to retry before giving up.
32    pub retries: u32,
33    /// Per-attempt connection timeout in seconds (0 disables).
34    pub connect_timeout_secs: u32,
35    /// `statement_timeout` to set once connected (0 leaves it alone).
36    pub statement_timeout_secs: u32,
37    /// TCP keepalive interval in seconds (0 disables).
38    pub keepalive_secs: u32,
39}
40
41impl Default for TransportConfig {
42    fn default() -> Self {
43        // Mirrors `DatabaseConfig::default` so the two cannot drift apart.
44        Self {
45            ssl_mode: SslMode::Prefer,
46            ssl_root_cert: None,
47            retries: 0,
48            connect_timeout_secs: 30,
49            statement_timeout_secs: 0,
50            keepalive_secs: 120,
51        }
52    }
53}
54
55impl TransportConfig {
56    /// Extract the transport settings from a loaded `[database]` config.
57    pub fn from_database_config(db: &crate::config::DatabaseConfig) -> Self {
58        Self {
59            ssl_mode: db.ssl_mode,
60            ssl_root_cert: db.ssl_root_cert.clone(),
61            retries: db.connect_retries,
62            connect_timeout_secs: db.connect_timeout_secs,
63            statement_timeout_secs: db.statement_timeout_secs,
64            keepalive_secs: db.keepalive_secs,
65        }
66    }
67}
68
69/// Build a unique name for a throwaway schema or database.
70///
71/// `simulate` and `drift` each create a sandbox, work in it, and then drop it
72/// unconditionally — including when their own `CREATE` failed. That makes the
73/// name safety-critical rather than cosmetic: if two concurrent runs pick the
74/// same one, the loser's `CREATE` fails and its cleanup then drops the sandbox
75/// the *winner* is still using.
76///
77/// Both used to derive the name from a clock alone — milliseconds for
78/// `simulate`, whole seconds for `drift` — so a collision needed only two runs
79/// starting in the same tick. The process id and a random suffix make the name
80/// unique across concurrent processes as well as within one.
81///
82/// Stays inside PostgreSQL's 63-byte and MySQL's 64-byte identifier limits for
83/// the prefixes used here.
84pub fn sandbox_name(prefix: &str) -> String {
85    let millis = std::time::SystemTime::now()
86        .duration_since(std::time::UNIX_EPOCH)
87        .unwrap_or_default()
88        .as_millis();
89    format!(
90        "{}_{}_{:x}_{:08x}",
91        prefix,
92        millis,
93        std::process::id(),
94        fastrand::u32(..)
95    )
96}
97
98/// Quote a value as a SQL string literal.
99///
100/// Doubles any embedded single quote, which is the escape both PostgreSQL and
101/// MySQL accept. Use this for anything that is *data* inside generated SQL —
102/// enum labels, for instance — as opposed to an object name, which wants
103/// [`quote_ident`].
104///
105/// Generated DDL used to interpolate enum labels with a bare `format!("'{}'")`,
106/// so a label containing an apostrophe produced
107/// `CREATE TYPE "mood" AS ENUM ('fine', 'it's bad')` — broken SQL in the
108/// snapshot, which `restore` then skipped with only a warning.
109pub fn quote_literal(value: &str) -> String {
110    format!("'{}'", value.replace('\'', "''"))
111}
112
113/// Quote a SQL identifier to prevent SQL injection.
114///
115/// Doubles any embedded double-quotes and wraps in double-quotes — this is the
116/// PostgreSQL convention. For MySQL identifier quoting use the dialect's
117/// [`DatabaseDialect::quote_ident`].
118pub fn quote_ident(name: &str) -> String {
119    format!("\"{}\"", name.replace('"', "\"\""))
120}
121
122/// Quote a SQL identifier the MySQL way: backticks, with embedded backticks
123/// doubled.
124///
125/// The MySQL command paths build DDL from names read out of
126/// `information_schema`. Those are server-provided rather than user-supplied,
127/// but an identifier containing a backtick would still produce broken SQL, and
128/// quoting uniformly means no call site has to reason about which names are
129/// "safe". Mirrors [`quote_ident`], which does the same for PostgreSQL.
130pub fn quote_ident_mysql(name: &str) -> String {
131    format!("`{}`", name.replace('`', "``"))
132}
133
134/// Validate that a SQL identifier contains only safe characters.
135///
136/// Returns an error for names with characters outside `[a-zA-Z0-9_]`.
137/// Even with quoting (defense in depth), we reject suspicious identifiers early.
138pub fn validate_identifier(name: &str) -> Result<()> {
139    if name.is_empty() {
140        return Err(WaypointError::ConfigError(
141            "Identifier cannot be empty".to_string(),
142        ));
143    }
144    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
145        return Err(WaypointError::ConfigError(format!(
146            "Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
147            name
148        )));
149    }
150    Ok(())
151}
152
153/// Engine-specific database connection wrapper.
154///
155/// Constructed by [`Waypoint::new`](crate::Waypoint::new) (which auto-detects
156/// the engine from the connection URL) or by [`DbClient::with_postgres`] /
157/// [`DbClient::with_mysql`] for callers that already have a connection.
158///
159/// Most internal command code currently still operates on a raw
160/// `tokio_postgres::Client` obtained via [`Self::as_postgres`]. As MySQL support
161/// rolls out command-by-command, those call sites move to dialect-aware code.
162pub enum DbClient {
163    /// PostgreSQL connection.
164    #[cfg(feature = "postgres")]
165    Postgres(Client),
166    /// MySQL connection pool. We use a pool because `mysql_async::Conn` requires
167    /// `&mut self` for queries, which would force every command to take
168    /// `&mut DbClient` — disruptive to the existing API. The pool exposes a
169    /// `&self` checkout API.
170    #[cfg(feature = "mysql")]
171    Mysql(mysql_async::Pool),
172}
173
174impl DbClient {
175    /// Wrap an existing PostgreSQL client.
176    #[cfg(feature = "postgres")]
177    pub fn with_postgres(client: Client) -> Self {
178        DbClient::Postgres(client)
179    }
180
181    /// Wrap an existing MySQL pool.
182    #[cfg(feature = "mysql")]
183    pub fn with_mysql(pool: mysql_async::Pool) -> Self {
184        DbClient::Mysql(pool)
185    }
186
187    /// Identify which dialect this connection is for.
188    pub fn dialect_kind(&self) -> DialectKind {
189        match self {
190            #[cfg(feature = "postgres")]
191            DbClient::Postgres(_) => DialectKind::Postgres,
192            #[cfg(feature = "mysql")]
193            DbClient::Mysql(_) => DialectKind::Mysql,
194        }
195    }
196
197    /// Borrow the dialect helper for this connection.
198    ///
199    /// Both `PostgresDialect` and `MysqlDialect` are zero-sized, so this returns
200    /// a static reference rather than allocating a new `Box` per call.
201    pub fn dialect(&self) -> &'static dyn DatabaseDialect {
202        #[cfg(feature = "postgres")]
203        static PG: crate::dialect::postgres::PostgresDialect =
204            crate::dialect::postgres::PostgresDialect;
205        #[cfg(feature = "mysql")]
206        static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
207        match self.dialect_kind() {
208            #[cfg(feature = "postgres")]
209            DialectKind::Postgres => &PG,
210            #[cfg(not(feature = "postgres"))]
211            DialectKind::Postgres => {
212                panic!("PostgreSQL connection without `postgres` feature compiled in")
213            }
214            #[cfg(feature = "mysql")]
215            DialectKind::Mysql => &MY,
216            #[cfg(not(feature = "mysql"))]
217            DialectKind::Mysql => {
218                panic!("MySQL connection without `mysql` feature compiled in")
219            }
220        }
221    }
222
223    /// Borrow the inner PostgreSQL client. Returns an error if this DbClient
224    /// is not a PostgreSQL connection — used as a transitional bridge for
225    /// command code that hasn't been ported to dialect-aware operation yet.
226    #[cfg(feature = "postgres")]
227    pub fn as_postgres(&self) -> Result<&Client> {
228        match self {
229            DbClient::Postgres(c) => Ok(c),
230            #[cfg(feature = "mysql")]
231            DbClient::Mysql(_) => Err(WaypointError::ConfigError(
232                "This operation is not yet implemented for MySQL".into(),
233            )),
234        }
235    }
236
237    /// Borrow the inner MySQL pool. Returns an error if this DbClient is not
238    /// a MySQL connection.
239    #[cfg(feature = "mysql")]
240    pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
241        match self {
242            DbClient::Mysql(p) => Ok(p),
243            #[cfg(feature = "postgres")]
244            DbClient::Postgres(_) => Err(WaypointError::ConfigError(
245                "This operation requires a MySQL connection".into(),
246            )),
247        }
248    }
249
250    /// Verify the database connection is still alive with a minimal round-trip.
251    pub async fn check_connection(&self) -> Result<()> {
252        match self {
253            #[cfg(feature = "postgres")]
254            DbClient::Postgres(c) => check_connection(c).await,
255            #[cfg(feature = "mysql")]
256            DbClient::Mysql(pool) => {
257                use mysql_async::prelude::*;
258                let mut conn =
259                    pool.get_conn()
260                        .await
261                        .map_err(|e| WaypointError::ConnectionLost {
262                            operation: "health check".into(),
263                            detail: e.to_string(),
264                        })?;
265                conn.query_drop("DO 0")
266                    .await
267                    .map_err(|e| WaypointError::ConnectionLost {
268                        operation: "health check".into(),
269                        detail: e.to_string(),
270                    })?;
271                Ok(())
272            }
273        }
274    }
275
276    /// Acquire a session-scoped advisory lock keyed by the history-table name.
277    ///
278    /// PostgreSQL: `pg_advisory_lock(<i64>)` derived from a CRC32 of the table name.
279    /// MySQL: `GET_LOCK('waypoint_<table>', -1)` (named, indefinite-wait).
280    pub async fn acquire_lock(&self, schema: &str, table_name: &str) -> Result<()> {
281        match self {
282            #[cfg(feature = "postgres")]
283            DbClient::Postgres(c) => acquire_advisory_lock(c, schema, table_name).await,
284            #[cfg(feature = "mysql")]
285            DbClient::Mysql(pool) => {
286                use mysql_async::prelude::*;
287                let key = mysql_lock_key(schema, table_name);
288                let mut conn = pool.get_conn().await?;
289                let acquired: Option<i64> = conn
290                    .exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
291                    .await?;
292                match acquired {
293                    Some(1) => {
294                        park_lock_conn(pool, &key, conn);
295                        Ok(())
296                    }
297                    _ => Err(WaypointError::LockError(format!(
298                        "Failed to acquire MySQL named lock {}",
299                        key
300                    ))),
301                }
302            }
303        }
304    }
305
306    /// Try to acquire the advisory lock, polling until acquired or timeout expires.
307    pub async fn acquire_lock_with_timeout(
308        &self,
309        schema: &str,
310        table_name: &str,
311        timeout_secs: u32,
312    ) -> Result<()> {
313        match self {
314            #[cfg(feature = "postgres")]
315            DbClient::Postgres(c) => {
316                acquire_advisory_lock_with_timeout(c, schema, table_name, timeout_secs).await
317            }
318            #[cfg(feature = "mysql")]
319            DbClient::Mysql(pool) => {
320                use mysql_async::prelude::*;
321                let key = mysql_lock_key(schema, table_name);
322                let mut conn = pool.get_conn().await?;
323                let acquired: Option<i64> = conn
324                    .exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
325                    .await?;
326                match acquired {
327                    Some(1) => {
328                        park_lock_conn(pool, &key, conn);
329                        Ok(())
330                    }
331                    Some(0) => Err(WaypointError::LockError(format!(
332                        "Timed out waiting for MySQL named lock {} after {}s",
333                        key, timeout_secs
334                    ))),
335                    _ => Err(WaypointError::LockError(format!(
336                        "Failed to acquire MySQL named lock {} (NULL result)",
337                        key
338                    ))),
339                }
340            }
341        }
342    }
343
344    /// Release the advisory lock acquired via [`Self::acquire_lock`].
345    pub async fn release_lock(&self, schema: &str, table_name: &str) -> Result<()> {
346        match self {
347            #[cfg(feature = "postgres")]
348            DbClient::Postgres(c) => release_advisory_lock(c, schema, table_name).await,
349            #[cfg(feature = "mysql")]
350            DbClient::Mysql(pool) => {
351                use mysql_async::prelude::*;
352                let key = mysql_lock_key(schema, table_name);
353                // Release on the *same* session that acquired it. A different
354                // connection's RELEASE_LOCK is a silent no-op (returns 0) and
355                // would leak the lock until the server reaps the session.
356                let mut conn = match unpark_lock_conn(pool, &key) {
357                    Some(conn) => conn,
358                    None => {
359                        return Err(WaypointError::LockError(format!(
360                            "No pinned connection holds MySQL named lock {} — \
361                             release_lock called without a matching acquire_lock",
362                            key
363                        )));
364                    }
365                };
366                let released = conn
367                    .exec_first::<Option<i64>, _, _>("SELECT RELEASE_LOCK(?)", (key.clone(),))
368                    .await;
369                // Return the connection to the pool either way; dropping it
370                // here also drops the lock, so a failed RELEASE_LOCK is not
371                // fatal — the session reset on return clears it.
372                drop(conn);
373                match released {
374                    Ok(Some(Some(1))) => Ok(()),
375                    Ok(_) => {
376                        log::warn!(
377                            "RELEASE_LOCK({}) did not report success; the lock is released \
378                             regardless because the holding session was returned to the pool",
379                            key
380                        );
381                        Ok(())
382                    }
383                    Err(e) => Err(WaypointError::MysqlError(e)),
384                }
385            }
386        }
387    }
388
389    /// Get the current database user/account.
390    pub async fn current_user(&self) -> Result<String> {
391        match self {
392            #[cfg(feature = "postgres")]
393            DbClient::Postgres(c) => get_current_user(c).await,
394            #[cfg(feature = "mysql")]
395            DbClient::Mysql(pool) => {
396                use mysql_async::prelude::*;
397                let mut conn = pool.get_conn().await?;
398                let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
399                user.ok_or_else(|| {
400                    WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
401                })
402            }
403        }
404    }
405
406    /// Get the current database name.
407    pub async fn current_database(&self) -> Result<String> {
408        match self {
409            #[cfg(feature = "postgres")]
410            DbClient::Postgres(c) => get_current_database(c).await,
411            #[cfg(feature = "mysql")]
412            DbClient::Mysql(pool) => {
413                use mysql_async::prelude::*;
414                let mut conn = pool.get_conn().await?;
415                // DATABASE() returns NULL when no schema is selected on the connection
416                let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
417                match db.flatten() {
418                    Some(name) => Ok(name),
419                    None => Err(WaypointError::ConfigError(
420                        "MySQL connection has no current database (none selected in URL)".into(),
421                    )),
422                }
423            }
424        }
425    }
426
427    /// Resolve the schema/database name to use for the history table.
428    ///
429    /// On PostgreSQL the configured value is used as-is. On MySQL there is no
430    /// schema concept distinct from the database; if the configured value is
431    /// the PG-default `"public"`, we fall back to the connection's current
432    /// database so a PG-shaped config keeps working when pointed at MySQL.
433    pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
434        match self.dialect_kind() {
435            DialectKind::Postgres => Ok(configured.to_string()),
436            DialectKind::Mysql => {
437                if configured == "public" {
438                    self.current_database().await
439                } else {
440                    Ok(configured.to_string())
441                }
442            }
443        }
444    }
445
446    /// Run one or more `;`-separated SQL statements without an explicit transaction.
447    ///
448    /// On PostgreSQL this is a single `batch_execute` call. On MySQL it splits
449    /// the batch into individual statements via
450    /// [`crate::sql_parser::split_mysql_statements`] (mysql_async's underlying
451    /// protocol doesn't accept multiple statements unless the connection is
452    /// built with `CLIENT_MULTI_STATEMENTS`, which we deliberately avoid).
453    /// Returns elapsed time in milliseconds.
454    pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
455        match self {
456            #[cfg(feature = "postgres")]
457            DbClient::Postgres(c) => execute_raw(c, sql).await,
458            #[cfg(feature = "mysql")]
459            DbClient::Mysql(pool) => {
460                use mysql_async::prelude::*;
461                let start = std::time::Instant::now();
462                let mut conn = pool.get_conn().await?;
463                for stmt in crate::sql_parser::split_mysql_statements(sql) {
464                    conn.query_drop(&stmt).await?;
465                }
466                Ok(start.elapsed().as_millis() as i32)
467            }
468        }
469    }
470
471    /// Run SQL inside a transaction where the engine supports DDL rollback.
472    ///
473    /// On PostgreSQL this issues `BEGIN` / `COMMIT` (with `ROLLBACK` on failure)
474    /// around `batch_execute`. On MySQL most DDL implicitly commits, so a
475    /// transaction wrapper provides no rollback guarantee for DDL — we issue
476    /// the statements without a wrapper and surface failures as they arise.
477    /// Callers needing strict batch atomicity should consult
478    /// [`DatabaseDialect::supports_transactional_ddl`] before invoking.
479    pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
480        match self {
481            #[cfg(feature = "postgres")]
482            DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
483            #[cfg(feature = "mysql")]
484            DbClient::Mysql(_) => self.execute_raw(sql).await,
485        }
486    }
487}
488
489/// Connect to whichever backend the URL scheme indicates.
490///
491/// The single place that maps a connection string to a [`DbClient`]. Engine is
492/// taken from the URL scheme (`postgres://` / `postgresql://` → PostgreSQL,
493/// `mysql://` → MySQL); anything else — notably libpq `key=value` strings —
494/// falls back to `config.database.engine`, which defaults to PostgreSQL.
495///
496/// PostgreSQL connections pick up the full `[database]` transport config
497/// (SSL mode, retries, timeouts, keepalive).
498pub async fn connect_for_url(
499    conn_string: &str,
500    #[cfg_attr(
501        not(any(feature = "postgres", feature = "mysql")),
502        allow(unused_variables)
503    )]
504    config: &crate::config::WaypointConfig,
505) -> Result<DbClient> {
506    let kind = DialectKind::from_url(conn_string).unwrap_or(config.database.engine);
507    match kind {
508        #[cfg(feature = "postgres")]
509        DialectKind::Postgres => {
510            let transport = TransportConfig::from_database_config(&config.database);
511            let client = connect_with_transport(conn_string, &transport).await?;
512            Ok(DbClient::with_postgres(client))
513        }
514        #[cfg(not(feature = "postgres"))]
515        DialectKind::Postgres => Err(WaypointError::ConfigError(
516            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
517        )),
518        #[cfg(feature = "mysql")]
519        DialectKind::Mysql => {
520            let pool = connect_mysql_pool(
521                conn_string,
522                config.database.ssl_mode,
523                config.database.ssl_root_cert.as_deref(),
524                config.database.statement_timeout_secs,
525                config.database.keepalive_secs,
526            )
527            .await?;
528            Ok(DbClient::with_mysql(pool))
529        }
530        #[cfg(not(feature = "mysql"))]
531        DialectKind::Mysql => Err(WaypointError::ConfigError(
532            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
533        )),
534    }
535}
536
537/// Build a MySQL pool with TLS configured from the `[database]` settings.
538///
539/// Before 0.7.0 this was a bare `Pool::from_url`, which meant `ssl_mode` was
540/// ignored entirely on MySQL and connections ran in plaintext unless the URL
541/// itself carried `require_ssl=true`.
542///
543/// # The `prefer` probe
544///
545/// `mysql_async` has no opportunistic TLS: attaching `SslOpts` makes TLS
546/// *mandatory*, and `Pool` is lazy, so a handshake failure would not surface
547/// until the first query. To give `prefer` its libpq meaning we therefore
548/// connect once eagerly and rebuild the pool without TLS if that fails. The
549/// probe connection is returned to the pool rather than discarded, so the
550/// successful path costs nothing extra. Every other mode stays lazy.
551///
552/// # `statement_timeout`
553///
554/// Applied as `SET SESSION MAX_EXECUTION_TIME`, which `docs/ENGINES.md` has
555/// always documented but the code never issued — the setting was silently
556/// ignored on MySQL. Note the scope the docs already state: MySQL's
557/// `MAX_EXECUTION_TIME` bounds **read-only SELECTs only**, so unlike
558/// PostgreSQL's `statement_timeout` it will not interrupt a long `ALTER TABLE`.
559/// MySQL has no server-side equivalent that does.
560///
561/// # Transport settings that do not apply
562///
563/// `connect_timeout` has no counterpart in `mysql_async` 0.37, and
564/// `connect_retries` has nothing to retry: `Pool` is lazy, so there is no
565/// connect step here to wrap in a loop. Both are recorded as PostgreSQL-only in
566/// `docs/ENGINES.md` rather than being silently dropped.
567#[cfg(feature = "mysql")]
568async fn connect_mysql_pool(
569    conn_string: &str,
570    ssl_mode: SslMode,
571    ssl_root_cert: Option<&std::path::Path>,
572    statement_timeout_secs: u32,
573    keepalive_secs: u32,
574) -> Result<mysql_async::Pool> {
575    let base = mysql_async::Opts::from_url(conn_string)
576        .map_err(|e| WaypointError::ConfigError(format!("Invalid MySQL connection URL: {}", e)))?;
577
578    // `setup`, not `init`: the pool issues `COM_RESET_CONNECTION` when a `Conn`
579    // is returned, which clears session variables. `init` runs only when the
580    // connection is first opened, so the timeout survived exactly one checkout
581    // and every later one silently ran unbounded. `setup` re-runs after each
582    // reset.
583    let mut builder = mysql_async::OptsBuilder::from_opts(base);
584
585    if statement_timeout_secs > 0 {
586        let millis = u64::from(statement_timeout_secs).saturating_mul(1000);
587        log::debug!(
588            "Setting MySQL MAX_EXECUTION_TIME={}ms (bounds SELECTs only; DDL is not interruptible \
589             by it)",
590            millis
591        );
592        builder = builder.setup(vec![format!("SET SESSION MAX_EXECUTION_TIME = {}", millis)]);
593    }
594
595    if keepalive_secs > 0 {
596        builder = builder.tcp_keepalive(Some(std::time::Duration::from_secs(u64::from(
597            keepalive_secs,
598        ))));
599    }
600
601    let base = mysql_async::Opts::from(builder);
602
603    // mysql_async writes the SSLRequest packet and then silently skips the
604    // upgrade for socket connections, handing back a plaintext session that
605    // reports success. Refuse instead of pretending the connection is
606    // encrypted.
607    if ssl_mode.requires_tls() && base.socket().is_some() {
608        return Err(WaypointError::ConfigError(format!(
609            "ssl_mode = '{}' requires TLS, but this MySQL connection uses a Unix \
610             socket, which the driver cannot secure. Use a TCP host:port, or set \
611             ssl_mode = 'disable'.",
612            ssl_mode
613        )));
614    }
615
616    // A URL that already spells out its TLS wishes (`require_ssl`, `verify_ca`,
617    // …) wins while ssl_mode is still at its default, mirroring how the
618    // PostgreSQL path treats an embedded `sslmode=`.
619    if ssl_mode == SslMode::Prefer && base.ssl_opts().is_some() {
620        log::debug!(
621            "Using the TLS options from the MySQL connection URL (ssl_mode is at its default)."
622        );
623        return Ok(mysql_async::Pool::new(base));
624    }
625
626    let Some(ssl_opts) = crate::tls::make_mysql_ssl_opts(ssl_mode, ssl_root_cert) else {
627        // ssl_mode = disable.
628        return Ok(mysql_async::Pool::new(base));
629    };
630
631    let secure = mysql_async::Pool::new(
632        mysql_async::OptsBuilder::from_opts(base.clone()).ssl_opts(Some(ssl_opts)),
633    );
634
635    if ssl_mode != SslMode::Prefer {
636        return Ok(secure);
637    }
638
639    match secure.get_conn().await {
640        Ok(conn) => {
641            drop(conn);
642            Ok(secure)
643        }
644        // Only retry in plaintext when the failure was actually about TLS.
645        // Falling back on *any* error would mask a wrong password behind a
646        // second, differently-failing attempt and double the authentication
647        // attempts against the server.
648        Err(e) if mysql_tls_unavailable(&e) => {
649            log::warn!(
650                "MySQL server does not support TLS ({}); continuing with an UNENCRYPTED \
651                 connection because ssl_mode is 'prefer'. Set ssl_mode to 'require' or \
652                 higher to refuse this.",
653                e
654            );
655            let _ = secure.disconnect().await;
656            Ok(mysql_async::Pool::new(base))
657        }
658        Err(e) => Err(WaypointError::MysqlError(e)),
659    }
660}
661
662/// Did this MySQL connection fail because TLS was unavailable, as opposed to
663/// for an unrelated reason like bad credentials or a refused connection?
664///
665/// Matched on the typed error rather than its `Display` text — the string
666/// approach is exactly what leaves `verify-ca` broken inside mysql_async
667/// itself (see `tls::make_mysql_ssl_opts`).
668#[cfg(feature = "mysql")]
669fn mysql_tls_unavailable(e: &mysql_async::Error) -> bool {
670    matches!(
671        e,
672        mysql_async::Error::Driver(mysql_async::DriverError::NoClientSslFlagFromServer)
673    ) || matches!(e, mysql_async::Error::Io(mysql_async::IoError::Tls(_)))
674}
675
676/// Compute the MySQL named-lock key for a history table in a given database.
677///
678/// # Scoping
679///
680/// MySQL `GET_LOCK` names live in a **server-global** namespace, unlike
681/// PostgreSQL advisory locks which are scoped to the current database. Keying
682/// on the table name alone therefore made every database on a shared MySQL
683/// server contend for one lock: migrating `app_staging` blocked a concurrent
684/// migration of `app_prod`, even though they share nothing. Including the
685/// database name restores per-database scoping and matches the PostgreSQL
686/// behaviour.
687///
688/// # Length
689///
690/// `GET_LOCK` names are capped at 64 characters on MySQL 8.0+. Plain
691/// truncation would let two distinct long `db.table` pairs collapse onto one
692/// key — silently over-serialising, or worse, letting a caller release a lock
693/// it does not hold. Over-long keys fall back to a CRC32 of the full name,
694/// which is stable across versions and platforms.
695#[cfg(feature = "mysql")]
696fn mysql_lock_key(schema: &str, table_name: &str) -> String {
697    let full = format!("waypoint_{}_{}", schema, table_name);
698    if full.len() <= 64 {
699        full
700    } else {
701        format!("waypoint_{:08x}", crc32fast::hash(full.as_bytes()))
702    }
703}
704
705/// Registry of pinned connections that currently hold a MySQL named lock.
706///
707/// `GET_LOCK` is **session**-scoped, and `mysql_async`'s pool defaults to
708/// `reset_connection = true`, which issues `COM_RESET_CONNECTION` when a
709/// `Conn` is returned to the pool. `COM_RESET_CONNECTION` explicitly releases
710/// locks acquired with `GET_LOCK()`. So acquiring the lock on a borrowed
711/// connection and dropping it back into the pool releases the lock
712/// immediately — the migration lock would provide no exclusion at all.
713///
714/// We therefore keep the acquiring `Conn` checked *out* of the pool for the
715/// whole lock lifetime, parked here, and release the lock on that same
716/// connection. A second acquire in the same process cannot get this
717/// connection back (it is not in the pool), so it takes a fresh session and
718/// blocks on `GET_LOCK` exactly as a separate process would.
719///
720/// Keyed by server identity + lock name so that a mixed-engine or
721/// multi-database run targeting two MySQL servers with the same history-table
722/// name keeps its locks distinct.
723#[cfg(feature = "mysql")]
724type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
725
726#[cfg(feature = "mysql")]
727static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
728    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
729
730/// Identity of the pool a lock was taken on, for registry keying.
731///
732/// The address of the `Pool` inside its owning [`DbClient`] scopes the entry to
733/// that specific client instance, which is what we want: two `DbClient`s
734/// pointing at different MySQL servers must not share a registry slot even
735/// when they use the same history-table name.
736///
737/// `acquire_lock` / `release_lock` are always called through the same
738/// `&DbClient` borrow (acquire, do work, release), so the value provably
739/// cannot move in between and the address is stable across the pair. If a
740/// caller were to move the owning `DbClient` while holding a lock, the parked
741/// connection would be orphaned and the lock would persist until the server
742/// reaps the session — degraded, but never silently unlocked.
743#[cfg(feature = "mysql")]
744fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
745    pool as *const mysql_async::Pool as usize
746}
747
748/// Park the lock-holding connection in the registry.
749#[cfg(feature = "mysql")]
750fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
751    let registry_key = (mysql_pool_ident(pool), key.to_string());
752    match MYSQL_LOCK_CONNS.lock() {
753        Ok(mut guard) => {
754            guard.insert(registry_key, conn);
755        }
756        Err(poisoned) => {
757            // A panic elsewhere poisoned the registry. Recover rather than
758            // propagate: losing the parked connection would leak the lock
759            // until the server times the session out.
760            poisoned.into_inner().insert(registry_key, conn);
761        }
762    }
763}
764
765/// Reclaim the lock-holding connection from the registry, if present.
766#[cfg(feature = "mysql")]
767fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
768    let registry_key = (mysql_pool_ident(pool), key.to_string());
769    match MYSQL_LOCK_CONNS.lock() {
770        Ok(mut guard) => guard.remove(&registry_key),
771        Err(poisoned) => poisoned.into_inner().remove(&registry_key),
772    }
773}
774
775// ── PostgreSQL-specific connection helpers (legacy entry points) ──────────────
776
777/// Translate waypoint's [`SslMode`] into tokio-postgres's own three-value mode.
778///
779/// tokio-postgres has no concept of `verify-ca` / `verify-full` — it only
780/// decides whether TLS is *attempted* or *demanded*. All three of our
781/// mandatory modes therefore map to `Require`, and the strength of the
782/// verification is expressed entirely in the rustls verifier built by
783/// [`crate::tls::make_rustls_config`].
784///
785/// Setting this at all is what makes `require` actually require TLS: without
786/// it tokio-postgres defaults to `Prefer` and silently accepts a server that
787/// refuses SSL.
788#[cfg(feature = "postgres")]
789fn to_pg_ssl_mode(mode: SslMode) -> tokio_postgres::config::SslMode {
790    match mode {
791        SslMode::Disable => tokio_postgres::config::SslMode::Disable,
792        SslMode::Prefer => tokio_postgres::config::SslMode::Prefer,
793        SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => {
794            tokio_postgres::config::SslMode::Require
795        }
796    }
797}
798
799/// Check if a postgres error is a permanent authentication failure that should not be retried.
800#[cfg(feature = "postgres")]
801fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
802    if let Some(db_err) = e.as_db_error() {
803        let code = db_err.code().code();
804        // 28P01 = invalid_password, 28000 = invalid_authorization_specification
805        return code == "28P01" || code == "28000";
806    }
807    false
808}
809
810/// Inject TCP keepalive parameters into a connection string if not already present.
811///
812/// For URL-style strings (`postgres://...`), appends `?keepalives=1&keepalives_idle=N`
813/// (or `&` if `?` already exists). For key=value style, appends ` keepalives=1 keepalives_idle=N`.
814/// Returns the string unchanged if `keepalive_secs == 0` or keepalive params already exist.
815pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
816    if keepalive_secs == 0 {
817        return conn_string.to_string();
818    }
819    let lower = conn_string.to_lowercase();
820    if lower.contains("keepalives") {
821        return conn_string.to_string();
822    }
823    let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
824    if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
825        if conn_string.contains('?') {
826            format!("{}&{}", conn_string, params)
827        } else {
828            format!("{}?{}", conn_string, params)
829        }
830    } else {
831        // Key=value style
832        format!(
833            "{} keepalives=1 keepalives_idle={}",
834            conn_string, keepalive_secs
835        )
836    }
837}
838
839/// Spawn the background connection driver task.
840///
841/// Both TLS and non-TLS connections produce a future that resolves when the
842/// connection terminates.  This helper accepts any such future and runs it
843/// on the tokio runtime, logging errors.
844#[cfg(feature = "postgres")]
845fn spawn_connection_task<F>(connection: F)
846where
847    F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
848        + Send
849        + 'static,
850{
851    tokio::spawn(async move {
852        if let Err(e) = connection.await {
853            log::error!("Database connection error: {}", e);
854        }
855    });
856}
857
858/// Make one connection attempt against an already-prepared config.
859///
860/// `pg_config` carries the enforced `ssl_mode`, and `tls_config` is `None`
861/// only for [`SslMode::Disable`]. Both are built once by the caller so a CA
862/// file is not re-read on every retry.
863///
864/// There is deliberately no outer plaintext retry for `prefer`. Now that the
865/// mode is pushed into `tokio_postgres::Config`, tokio-postgres performs the
866/// `prefer` downgrade *in band* — it sends the SSLRequest, and on the server's
867/// `N` reply continues on the same socket unencrypted. The old code instead
868/// caught every error from the TLS attempt and opened a second connection,
869/// which doubled the authentication attempts against the server (enough to
870/// trip lockout policies) and reported "falling back to plaintext" for
871/// failures that had nothing to do with TLS, such as a refused connection or a
872/// bad password.
873#[cfg(feature = "postgres")]
874async fn connect_once(
875    pg_config: &tokio_postgres::Config,
876    tls_config: Option<&rustls::ClientConfig>,
877    connect_timeout_secs: u32,
878) -> std::result::Result<Client, tokio_postgres::Error> {
879    let connect_fut = async {
880        match tls_config {
881            None => {
882                let (client, connection) = pg_config.connect(tokio_postgres::NoTls).await?;
883                spawn_connection_task(connection);
884                Ok(client)
885            }
886            Some(tls_config) => {
887                let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config.clone());
888                let (client, connection) = pg_config.connect(tls).await?;
889                spawn_connection_task(connection);
890                Ok(client)
891            }
892        }
893    };
894
895    if connect_timeout_secs > 0 {
896        match tokio::time::timeout(
897            std::time::Duration::from_secs(connect_timeout_secs as u64),
898            connect_fut,
899        )
900        .await
901        {
902            Ok(result) => result,
903            Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
904        }
905    } else {
906        connect_fut.await
907    }
908}
909
910/// Connect to the database using the provided connection string.
911///
912/// Spawns the connection task on the tokio runtime.
913#[cfg(feature = "postgres")]
914#[deprecated(
915    since = "0.7.0",
916    note = "Use connect_with_transport, which supports the full sslmode ladder and a custom CA. Will be removed in 1.0."
917)]
918pub async fn connect(conn_string: &str) -> Result<Client> {
919    connect_with_transport(conn_string, &TransportConfig::default()).await
920}
921
922/// Connect to the database, retrying up to `retries` times with exponential backoff + jitter.
923///
924/// Each retry waits `min(2^attempt, 30) + rand(0..1000ms)` before the next attempt.
925/// Permanent errors (authentication failures) are not retried.
926#[cfg(feature = "postgres")]
927#[deprecated(
928    since = "0.7.0",
929    note = "Use connect_with_transport, which supports the full sslmode ladder and a custom CA. Will be removed in 1.0."
930)]
931pub async fn connect_with_config(
932    conn_string: &str,
933    ssl_mode: &SslMode,
934    retries: u32,
935    connect_timeout_secs: u32,
936    statement_timeout_secs: u32,
937) -> Result<Client> {
938    connect_with_transport(
939        conn_string,
940        &TransportConfig {
941            ssl_mode: *ssl_mode,
942            retries,
943            connect_timeout_secs,
944            statement_timeout_secs,
945            ..TransportConfig::default()
946        },
947    )
948    .await
949}
950
951/// Connect to the database with all configuration options including TCP keepalive.
952#[cfg(feature = "postgres")]
953#[deprecated(
954    since = "0.7.0",
955    note = "Use connect_with_transport — this signature cannot express ssl_root_cert. Will be removed in 1.0."
956)]
957pub async fn connect_with_full_config(
958    conn_string: &str,
959    ssl_mode: &SslMode,
960    retries: u32,
961    connect_timeout_secs: u32,
962    statement_timeout_secs: u32,
963    keepalive_secs: u32,
964) -> Result<Client> {
965    connect_with_transport(
966        conn_string,
967        &TransportConfig {
968            ssl_mode: *ssl_mode,
969            ssl_root_cert: None,
970            retries,
971            connect_timeout_secs,
972            statement_timeout_secs,
973            keepalive_secs,
974        },
975    )
976    .await
977}
978
979/// Connect to PostgreSQL with retry, honouring the full TLS trust configuration.
980///
981/// Unlike the older helpers this actually *enforces* the requested
982/// [`SslMode`]: the mode is pushed into `tokio_postgres::Config`, so a server
983/// that refuses SSL is rejected under `require` and above rather than being
984/// silently downgraded to plaintext.
985#[cfg(feature = "postgres")]
986pub async fn connect_with_transport(
987    conn_string: &str,
988    transport: &TransportConfig,
989) -> Result<Client> {
990    let conn_string = inject_keepalive(conn_string, transport.keepalive_secs);
991
992    // Take libpq's `sslmode=` / `sslrootcert=` out of the string before
993    // tokio-postgres sees it — its parser rejects `verify-ca` / `verify-full`
994    // outright, and rejects `sslrootcert` as an unknown option.
995    let (conn_string, embedded) = crate::tls::parse_url_sslmode(&conn_string);
996    let ssl_mode = crate::tls::reconcile_ssl_mode(transport.ssl_mode, embedded.mode);
997    let ssl_root_cert =
998        crate::tls::reconcile_root_cert(transport.ssl_root_cert.as_deref(), embedded.root_cert);
999
1000    let mut pg_config: tokio_postgres::Config = conn_string.parse().map_err(|e| {
1001        WaypointError::ConfigError(format!("Invalid PostgreSQL connection string: {}", e))
1002    })?;
1003    pg_config.ssl_mode(to_pg_ssl_mode(ssl_mode));
1004
1005    // Built once, outside the retry loop, so the CA file is read at most once
1006    // and a bad path fails immediately instead of after every backoff.
1007    let tls_config = match ssl_mode {
1008        SslMode::Disable => None,
1009        _ => Some(crate::tls::make_rustls_config(
1010            ssl_mode,
1011            ssl_root_cert.as_deref(),
1012        )?),
1013    };
1014
1015    let retries = transport.retries;
1016    let mut last_err = None;
1017
1018    for attempt in 0..=retries {
1019        if attempt > 0 {
1020            let base_delay = std::cmp::min(1u64 << attempt, 30);
1021            let jitter_ms = fastrand::u64(0..1000);
1022            let delay = std::time::Duration::from_secs(base_delay)
1023                + std::time::Duration::from_millis(jitter_ms);
1024            log::info!(
1025                "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
1026                attempt + 1,
1027                retries + 1,
1028                delay.as_millis() as u64
1029            );
1030            tokio::time::sleep(delay).await;
1031        }
1032
1033        match connect_once(
1034            &pg_config,
1035            tls_config.as_ref(),
1036            transport.connect_timeout_secs,
1037        )
1038        .await
1039        {
1040            Ok(client) => {
1041                if attempt > 0 {
1042                    log::info!(
1043                        "Connected successfully after retry; attempt={}, max_attempts={}",
1044                        attempt + 1,
1045                        retries + 1
1046                    );
1047                }
1048
1049                // Set statement timeout if configured
1050                if transport.statement_timeout_secs > 0 {
1051                    let timeout_sql = format!(
1052                        "SET statement_timeout = '{}s'",
1053                        transport.statement_timeout_secs
1054                    );
1055                    client.batch_execute(&timeout_sql).await?;
1056                }
1057
1058                return Ok(client);
1059            }
1060            Err(e) => {
1061                // Don't retry permanent errors (e.g. bad credentials)
1062                if is_permanent_error(&e) {
1063                    log::error!("Permanent connection error, not retrying: {}", e);
1064                    return Err(WaypointError::DatabaseError(e));
1065                }
1066                last_err = Some(e);
1067            }
1068        }
1069    }
1070
1071    Err(WaypointError::DatabaseError(last_err.unwrap()))
1072}
1073
1074/// Acquire a PostgreSQL advisory lock based on the history table name.
1075///
1076/// This prevents concurrent migration runs from interfering with each other.
1077#[cfg(feature = "postgres")]
1078pub async fn acquire_advisory_lock(client: &Client, schema: &str, table_name: &str) -> Result<()> {
1079    let lock_id = advisory_lock_id(schema, table_name);
1080    log::info!(
1081        "Acquiring advisory lock; lock_id={}, table={}",
1082        lock_id,
1083        table_name
1084    );
1085
1086    client
1087        .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
1088        .await
1089        .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
1090
1091    Ok(())
1092}
1093
1094/// Try to acquire a PostgreSQL advisory lock with a timeout.
1095///
1096/// Uses `pg_try_advisory_lock()` in a polling loop with configurable timeout.
1097/// Returns Ok(()) if lock acquired, or a LockError if the timeout expires.
1098#[cfg(feature = "postgres")]
1099pub async fn acquire_advisory_lock_with_timeout(
1100    client: &Client,
1101    schema: &str,
1102    table_name: &str,
1103    timeout_secs: u32,
1104) -> Result<()> {
1105    let lock_id = advisory_lock_id(schema, table_name);
1106    log::info!(
1107        "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
1108        lock_id,
1109        table_name,
1110        timeout_secs
1111    );
1112
1113    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
1114
1115    loop {
1116        let row = client
1117            .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
1118            .await
1119            .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
1120
1121        let acquired: bool = row.get(0);
1122        if acquired {
1123            return Ok(());
1124        }
1125
1126        if std::time::Instant::now() >= deadline {
1127            return Err(WaypointError::LockError(format!(
1128                "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
1129                timeout_secs, table_name
1130            )));
1131        }
1132
1133        // Wait 500ms before retrying
1134        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1135    }
1136}
1137
1138/// Release the PostgreSQL advisory lock.
1139#[cfg(feature = "postgres")]
1140pub async fn release_advisory_lock(client: &Client, schema: &str, table_name: &str) -> Result<()> {
1141    let lock_id = advisory_lock_id(schema, table_name);
1142    log::info!(
1143        "Releasing advisory lock; lock_id={}, table={}",
1144        lock_id,
1145        table_name
1146    );
1147
1148    client
1149        .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
1150        .await
1151        .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
1152
1153    Ok(())
1154}
1155
1156/// Compute a stable i64 lock ID from the schema and table name using CRC32.
1157///
1158/// # Scoping
1159///
1160/// PostgreSQL advisory locks live in a per-*database* namespace, so two
1161/// databases never collide. Two **schemas** in the same database did: the key
1162/// used to be a hash of the table name alone, and every schema names its
1163/// history table the same thing. Schema-per-tenant and schema-per-service
1164/// layouts therefore serialised every migration in the database behind one
1165/// lock. It was never unsafe — over-locking cannot corrupt anything — but it
1166/// queued work that has nothing to do with each other.
1167///
1168/// Including the schema mirrors [`mysql_lock_key`], which has always been
1169/// scoped this way and whose test says why.
1170///
1171/// # Upgrading
1172///
1173/// **The key changed in 0.8.0.** A waypoint before 0.8.0 and a waypoint from
1174/// 0.8.0 onwards compute different ids for the same history table, so they do
1175/// **not** exclude each other. Finish rolling out the new version before
1176/// relying on the lock again — do not run migrations from a mixed fleet
1177/// against one database.
1178///
1179/// Uses CRC32 instead of DefaultHasher for cross-version stability —
1180/// DefaultHasher is not guaranteed to produce the same output across
1181/// Rust compiler versions.
1182pub fn advisory_lock_id(schema: &str, table_name: &str) -> i64 {
1183    // `\0` as the separator: it cannot appear in a PostgreSQL identifier, so
1184    // ("a", "b_c") and ("a_b", "c") cannot hash to the same key.
1185    let key = format!("{}\0{}", schema, table_name);
1186    crc32fast::hash(key.as_bytes()) as i64
1187}
1188
1189/// Get the current database user.
1190#[cfg(feature = "postgres")]
1191pub async fn get_current_user(client: &Client) -> Result<String> {
1192    let row = client.query_one("SELECT current_user", &[]).await?;
1193    Ok(row.get::<_, String>(0))
1194}
1195
1196/// Get the current database name.
1197#[cfg(feature = "postgres")]
1198pub async fn get_current_database(client: &Client) -> Result<String> {
1199    let row = client.query_one("SELECT current_database()", &[]).await?;
1200    Ok(row.get::<_, String>(0))
1201}
1202
1203/// Execute a SQL string within a transaction using SQL-level BEGIN/COMMIT.
1204/// Returns the execution time in milliseconds.
1205#[cfg(feature = "postgres")]
1206pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
1207    let start = std::time::Instant::now();
1208
1209    client.batch_execute("BEGIN").await?;
1210
1211    match client.batch_execute(sql).await {
1212        Ok(()) => {
1213            client.batch_execute("COMMIT").await?;
1214        }
1215        Err(e) => {
1216            if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
1217                log::warn!("Failed to rollback transaction: {}", rollback_err);
1218            }
1219            return Err(WaypointError::DatabaseError(e));
1220        }
1221    }
1222
1223    let elapsed = start.elapsed().as_millis() as i32;
1224    Ok(elapsed)
1225}
1226
1227/// Execute SQL without a transaction wrapper (for statements that can't run in a transaction).
1228#[cfg(feature = "postgres")]
1229pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
1230    let start = std::time::Instant::now();
1231    client.batch_execute(sql).await?;
1232    let elapsed = start.elapsed().as_millis() as i32;
1233    Ok(elapsed)
1234}
1235
1236/// Check if an error is a transient connection error that may be retried.
1237///
1238/// Detects PostgreSQL server shutdown codes, connection exception codes,
1239/// closed connections, and common network error message patterns.
1240pub fn is_transient_error(e: &WaypointError) -> bool {
1241    match e {
1242        #[cfg(feature = "postgres")]
1243        WaypointError::DatabaseError(pg_err) => {
1244            // Check if the connection is closed
1245            if pg_err.is_closed() {
1246                return true;
1247            }
1248            // Check PostgreSQL error codes
1249            if let Some(db_err) = pg_err.as_db_error() {
1250                let code = db_err.code().code();
1251                // 57P01 = admin_shutdown, 57P02 = crash_shutdown, 57P03 = cannot_connect_now
1252                // 08000 = connection_exception, 08003 = connection_does_not_exist,
1253                // 08006 = connection_failure
1254                return matches!(
1255                    code,
1256                    "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
1257                );
1258            }
1259            // Check error message patterns for connection-related issues
1260            let msg = pg_err.to_string().to_lowercase();
1261            msg.contains("connection reset")
1262                || msg.contains("broken pipe")
1263                || msg.contains("connection closed")
1264                || msg.contains("unexpected eof")
1265        }
1266        #[cfg(feature = "mysql")]
1267        WaypointError::MysqlError(my_err) => {
1268            // mysql_async surfaces server-shutdown / connection-reset as IO or
1269            // driver errors. Do a coarse string match for now; we'll refine when
1270            // we wire production retry logic for MySQL in Phase 1.
1271            let msg = my_err.to_string().to_lowercase();
1272            msg.contains("connection reset")
1273                || msg.contains("broken pipe")
1274                || msg.contains("connection closed")
1275                || msg.contains("server has gone away")
1276                || msg.contains("lost connection")
1277                || msg.contains("io error")
1278        }
1279        WaypointError::ConnectionLost { .. } => true,
1280        _ => false,
1281    }
1282}
1283
1284/// Verify the database connection is still alive with a minimal round-trip.
1285#[cfg(feature = "postgres")]
1286pub async fn check_connection(client: &Client) -> Result<()> {
1287    client
1288        .simple_query("")
1289        .await
1290        .map_err(|e| WaypointError::ConnectionLost {
1291            operation: "health check".to_string(),
1292            detail: e.to_string(),
1293        })?;
1294    Ok(())
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    // ── inject_keepalive tests ──
1302
1303    #[test]
1304    fn test_inject_keepalive_url_style() {
1305        let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
1306        assert_eq!(
1307            result,
1308            "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_inject_keepalive_url_with_existing_params() {
1314        let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
1315        assert_eq!(
1316            result,
1317            "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_inject_keepalive_kv_style() {
1323        let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
1324        assert_eq!(
1325            result,
1326            "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_inject_keepalive_zero_disables() {
1332        let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
1333        assert_eq!(result, "postgres://user:pass@localhost/db");
1334    }
1335
1336    #[test]
1337    fn test_inject_keepalive_already_present() {
1338        let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
1339        assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
1340    }
1341
1342    // ── is_transient_error tests ──
1343
1344    #[test]
1345    fn test_transient_error_connection_lost() {
1346        let err = WaypointError::ConnectionLost {
1347            operation: "test".to_string(),
1348            detail: "gone".to_string(),
1349        };
1350        assert!(is_transient_error(&err));
1351    }
1352
1353    #[test]
1354    fn test_transient_error_config_is_not_transient() {
1355        let err = WaypointError::ConfigError("bad config".to_string());
1356        assert!(!is_transient_error(&err));
1357    }
1358
1359    #[test]
1360    fn test_transient_error_migration_failed_is_not_transient() {
1361        let err = WaypointError::MigrationFailed {
1362            script: "V1__test.sql".to_string(),
1363            reason: "syntax error".to_string(),
1364        };
1365        assert!(!is_transient_error(&err));
1366    }
1367
1368    #[test]
1369    fn test_advisory_lock_id_stability() {
1370        // The same schema+table always produces the same lock ID; a
1371        // release_lock would otherwise not match its acquire_lock.
1372        let id1 = advisory_lock_id("public", "waypoint_schema_history");
1373        let id2 = advisory_lock_id("public", "waypoint_schema_history");
1374        assert_eq!(id1, id2);
1375        // Different table names produce different lock IDs.
1376        let id3 = advisory_lock_id("public", "other_table");
1377        assert_ne!(id1, id3);
1378    }
1379
1380    #[test]
1381    fn test_advisory_lock_id_is_scoped_per_schema() {
1382        // PostgreSQL advisory locks are per-database, so two *schemas* in one
1383        // database used to share a key — every tenant's migration queued
1384        // behind every other tenant's for no reason.
1385        let a = advisory_lock_id("tenant_a", "waypoint_schema_history");
1386        let b = advisory_lock_id("tenant_b", "waypoint_schema_history");
1387        assert_ne!(a, b, "schemas in one database must not share a lock");
1388    }
1389
1390    #[test]
1391    fn test_advisory_lock_id_separator_cannot_be_forged() {
1392        // A plain concatenation would make ("a", "b_c") and ("a_b", "c")
1393        // collide. The NUL separator cannot occur in an identifier.
1394        assert_ne!(
1395            advisory_lock_id("a", "b_c"),
1396            advisory_lock_id("a_b", "c"),
1397            "schema/table boundary must be unambiguous"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_transient_error_lock_error_is_not_transient() {
1403        let err = WaypointError::LockError("lock failed".to_string());
1404        assert!(!is_transient_error(&err));
1405    }
1406
1407    #[test]
1408    fn test_transient_error_io_error_is_not_transient() {
1409        let err = WaypointError::IoError(std::io::Error::new(
1410            std::io::ErrorKind::NotFound,
1411            "file not found",
1412        ));
1413        assert!(!is_transient_error(&err));
1414    }
1415
1416    #[test]
1417    fn test_validate_identifier_valid() {
1418        assert!(validate_identifier("users").is_ok());
1419        assert!(validate_identifier("my_table").is_ok());
1420        assert!(validate_identifier("Table123").is_ok());
1421        assert!(validate_identifier("a").is_ok());
1422    }
1423
1424    #[test]
1425    fn test_validate_identifier_invalid() {
1426        assert!(validate_identifier("").is_err());
1427        assert!(validate_identifier("my-table").is_err());
1428        assert!(validate_identifier("my table").is_err());
1429        assert!(validate_identifier("table.name").is_err());
1430        assert!(validate_identifier("table;drop").is_err());
1431    }
1432
1433    #[test]
1434    fn test_quote_ident_simple() {
1435        assert_eq!(quote_ident("users"), "\"users\"");
1436    }
1437
1438    #[test]
1439    fn test_quote_ident_embedded_quotes() {
1440        assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1441    }
1442
1443    #[test]
1444    fn test_quote_ident_empty() {
1445        assert_eq!(quote_ident(""), "\"\"");
1446    }
1447
1448    #[test]
1449    fn test_inject_keepalive_postgresql_prefix() {
1450        let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1451        assert_eq!(
1452            result,
1453            "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1454        );
1455    }
1456
1457    #[cfg(feature = "mysql")]
1458    #[test]
1459    fn mysql_lock_key_is_scoped_per_database() {
1460        // GET_LOCK names are server-global, so the same history table in two
1461        // databases must not collide — otherwise migrating one database blocks
1462        // migrating the other.
1463        let a = mysql_lock_key("app_prod", "waypoint_schema_history");
1464        let b = mysql_lock_key("app_staging", "waypoint_schema_history");
1465        assert_ne!(a, b);
1466        assert_eq!(a, "waypoint_app_prod_waypoint_schema_history");
1467    }
1468
1469    #[cfg(feature = "mysql")]
1470    #[test]
1471    fn mysql_lock_key_respects_the_64_char_limit() {
1472        let long_db = "d".repeat(60);
1473        let long_tbl = "t".repeat(60);
1474        let k = mysql_lock_key(&long_db, &long_tbl);
1475        assert!(
1476            k.len() <= 64,
1477            "GET_LOCK names are capped at 64: {}",
1478            k.len()
1479        );
1480    }
1481
1482    #[cfg(feature = "mysql")]
1483    #[test]
1484    fn mysql_lock_key_does_not_collide_after_shortening() {
1485        // Two distinct over-long names must not fold onto the same key —
1486        // plain truncation would have made these identical.
1487        let prefix = "x".repeat(60);
1488        let a = mysql_lock_key(&prefix, "alpha");
1489        let b = mysql_lock_key(&prefix, "beta");
1490        assert!(a.len() <= 64 && b.len() <= 64);
1491        assert_ne!(a, b, "distinct tables collapsed onto one lock key");
1492    }
1493
1494    #[cfg(feature = "mysql")]
1495    #[test]
1496    fn mysql_lock_key_is_stable() {
1497        // The key has to be reproducible across processes and releases, or a
1498        // release_lock would not match its acquire_lock.
1499        assert_eq!(mysql_lock_key("db", "tbl"), mysql_lock_key("db", "tbl"));
1500    }
1501
1502    #[test]
1503    fn test_sandbox_name_is_unique_across_rapid_calls() {
1504        // `simulate` and `drift` drop their sandbox unconditionally, so two
1505        // runs that pick the same name destroy each other's work. The names
1506        // used to be a bare clock reading — milliseconds for simulate, whole
1507        // seconds for drift — and a loop this tight produced duplicates.
1508        let names: std::collections::HashSet<String> =
1509            (0..2000).map(|_| sandbox_name("waypoint_sim")).collect();
1510        assert_eq!(
1511            names.len(),
1512            2000,
1513            "sandbox names collided within a single tight loop"
1514        );
1515    }
1516
1517    #[test]
1518    fn test_sandbox_name_fits_identifier_limits() {
1519        // PostgreSQL truncates identifiers at 63 bytes and MySQL rejects
1520        // database names over 64. A truncated name would reintroduce exactly
1521        // the collision this helper exists to prevent.
1522        for prefix in ["waypoint_sim", "waypoint_drift_check"] {
1523            let name = sandbox_name(prefix);
1524            assert!(
1525                name.len() <= 63,
1526                "{} is {} bytes, over PostgreSQL's 63-byte limit",
1527                name,
1528                name.len()
1529            );
1530            assert!(name.starts_with(prefix));
1531        }
1532    }
1533
1534    #[test]
1535    fn test_quote_literal_escapes_embedded_single_quotes() {
1536        // Generated enum DDL used to interpolate labels raw, producing
1537        // `ENUM ('fine', 'it's bad')` — a snapshot that will not restore.
1538        assert_eq!(quote_literal("fine"), "'fine'");
1539        assert_eq!(quote_literal("it's bad"), "'it''s bad'");
1540        // Two quotes in: each is doubled, then the delimiters are added.
1541        assert_eq!(quote_literal("''"), r"''''''");
1542        assert_eq!(quote_literal(""), r"''");
1543    }
1544
1545    #[test]
1546    fn test_quote_literal_leaves_other_characters_alone() {
1547        // Backslashes are not escapes in a standard-conforming string literal,
1548        // so doubling them would corrupt the value.
1549        assert_eq!(quote_literal(r"back\slash"), r"'back\slash'");
1550        assert_eq!(quote_literal("multi\nline"), "'multi\nline'");
1551    }
1552}