1use 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
20pub fn quote_ident(name: &str) -> String {
26 format!("\"{}\"", name.replace('"', "\"\""))
27}
28
29pub fn quote_ident_mysql(name: &str) -> String {
38 format!("`{}`", name.replace('`', "``"))
39}
40
41pub fn validate_identifier(name: &str) -> Result<()> {
46 if name.is_empty() {
47 return Err(WaypointError::ConfigError(
48 "Identifier cannot be empty".to_string(),
49 ));
50 }
51 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
52 return Err(WaypointError::ConfigError(format!(
53 "Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
54 name
55 )));
56 }
57 Ok(())
58}
59
60pub enum DbClient {
70 #[cfg(feature = "postgres")]
72 Postgres(Client),
73 #[cfg(feature = "mysql")]
78 Mysql(mysql_async::Pool),
79}
80
81impl DbClient {
82 #[cfg(feature = "postgres")]
84 pub fn with_postgres(client: Client) -> Self {
85 DbClient::Postgres(client)
86 }
87
88 #[cfg(feature = "mysql")]
90 pub fn with_mysql(pool: mysql_async::Pool) -> Self {
91 DbClient::Mysql(pool)
92 }
93
94 pub fn dialect_kind(&self) -> DialectKind {
96 match self {
97 #[cfg(feature = "postgres")]
98 DbClient::Postgres(_) => DialectKind::Postgres,
99 #[cfg(feature = "mysql")]
100 DbClient::Mysql(_) => DialectKind::Mysql,
101 }
102 }
103
104 pub fn dialect(&self) -> &'static dyn DatabaseDialect {
109 #[cfg(feature = "postgres")]
110 static PG: crate::dialect::postgres::PostgresDialect =
111 crate::dialect::postgres::PostgresDialect;
112 #[cfg(feature = "mysql")]
113 static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
114 match self.dialect_kind() {
115 #[cfg(feature = "postgres")]
116 DialectKind::Postgres => &PG,
117 #[cfg(not(feature = "postgres"))]
118 DialectKind::Postgres => {
119 panic!("PostgreSQL connection without `postgres` feature compiled in")
120 }
121 #[cfg(feature = "mysql")]
122 DialectKind::Mysql => &MY,
123 #[cfg(not(feature = "mysql"))]
124 DialectKind::Mysql => {
125 panic!("MySQL connection without `mysql` feature compiled in")
126 }
127 }
128 }
129
130 #[cfg(feature = "postgres")]
134 pub fn as_postgres(&self) -> Result<&Client> {
135 match self {
136 DbClient::Postgres(c) => Ok(c),
137 #[cfg(feature = "mysql")]
138 DbClient::Mysql(_) => Err(WaypointError::ConfigError(
139 "This operation is not yet implemented for MySQL".into(),
140 )),
141 }
142 }
143
144 #[cfg(feature = "mysql")]
147 pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
148 match self {
149 DbClient::Mysql(p) => Ok(p),
150 #[cfg(feature = "postgres")]
151 DbClient::Postgres(_) => Err(WaypointError::ConfigError(
152 "This operation requires a MySQL connection".into(),
153 )),
154 }
155 }
156
157 pub async fn check_connection(&self) -> Result<()> {
159 match self {
160 #[cfg(feature = "postgres")]
161 DbClient::Postgres(c) => check_connection(c).await,
162 #[cfg(feature = "mysql")]
163 DbClient::Mysql(pool) => {
164 use mysql_async::prelude::*;
165 let mut conn =
166 pool.get_conn()
167 .await
168 .map_err(|e| WaypointError::ConnectionLost {
169 operation: "health check".into(),
170 detail: e.to_string(),
171 })?;
172 conn.query_drop("DO 0")
173 .await
174 .map_err(|e| WaypointError::ConnectionLost {
175 operation: "health check".into(),
176 detail: e.to_string(),
177 })?;
178 Ok(())
179 }
180 }
181 }
182
183 pub async fn acquire_lock(&self, table_name: &str) -> Result<()> {
188 match self {
189 #[cfg(feature = "postgres")]
190 DbClient::Postgres(c) => acquire_advisory_lock(c, table_name).await,
191 #[cfg(feature = "mysql")]
192 DbClient::Mysql(pool) => {
193 use mysql_async::prelude::*;
194 let key = mysql_lock_key(&mysql_lock_scope(self).await, table_name);
195 let mut conn = pool.get_conn().await?;
196 let acquired: Option<i64> = conn
197 .exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
198 .await?;
199 match acquired {
200 Some(1) => {
201 park_lock_conn(pool, &key, conn);
202 Ok(())
203 }
204 _ => Err(WaypointError::LockError(format!(
205 "Failed to acquire MySQL named lock {}",
206 key
207 ))),
208 }
209 }
210 }
211 }
212
213 pub async fn acquire_lock_with_timeout(
215 &self,
216 table_name: &str,
217 timeout_secs: u32,
218 ) -> Result<()> {
219 match self {
220 #[cfg(feature = "postgres")]
221 DbClient::Postgres(c) => {
222 acquire_advisory_lock_with_timeout(c, table_name, timeout_secs).await
223 }
224 #[cfg(feature = "mysql")]
225 DbClient::Mysql(pool) => {
226 use mysql_async::prelude::*;
227 let key = mysql_lock_key(&mysql_lock_scope(self).await, table_name);
228 let mut conn = pool.get_conn().await?;
229 let acquired: Option<i64> = conn
230 .exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
231 .await?;
232 match acquired {
233 Some(1) => {
234 park_lock_conn(pool, &key, conn);
235 Ok(())
236 }
237 Some(0) => Err(WaypointError::LockError(format!(
238 "Timed out waiting for MySQL named lock {} after {}s",
239 key, timeout_secs
240 ))),
241 _ => Err(WaypointError::LockError(format!(
242 "Failed to acquire MySQL named lock {} (NULL result)",
243 key
244 ))),
245 }
246 }
247 }
248 }
249
250 pub async fn release_lock(&self, table_name: &str) -> Result<()> {
252 match self {
253 #[cfg(feature = "postgres")]
254 DbClient::Postgres(c) => release_advisory_lock(c, table_name).await,
255 #[cfg(feature = "mysql")]
256 DbClient::Mysql(pool) => {
257 use mysql_async::prelude::*;
258 let key = mysql_lock_key(&mysql_lock_scope(self).await, table_name);
259 let mut conn = match unpark_lock_conn(pool, &key) {
263 Some(conn) => conn,
264 None => {
265 return Err(WaypointError::LockError(format!(
266 "No pinned connection holds MySQL named lock {} — \
267 release_lock called without a matching acquire_lock",
268 key
269 )));
270 }
271 };
272 let released = conn
273 .exec_first::<Option<i64>, _, _>("SELECT RELEASE_LOCK(?)", (key.clone(),))
274 .await;
275 drop(conn);
279 match released {
280 Ok(Some(Some(1))) => Ok(()),
281 Ok(_) => {
282 log::warn!(
283 "RELEASE_LOCK({}) did not report success; the lock is released \
284 regardless because the holding session was returned to the pool",
285 key
286 );
287 Ok(())
288 }
289 Err(e) => Err(WaypointError::MysqlError(e)),
290 }
291 }
292 }
293 }
294
295 pub async fn current_user(&self) -> Result<String> {
297 match self {
298 #[cfg(feature = "postgres")]
299 DbClient::Postgres(c) => get_current_user(c).await,
300 #[cfg(feature = "mysql")]
301 DbClient::Mysql(pool) => {
302 use mysql_async::prelude::*;
303 let mut conn = pool.get_conn().await?;
304 let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
305 user.ok_or_else(|| {
306 WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
307 })
308 }
309 }
310 }
311
312 pub async fn current_database(&self) -> Result<String> {
314 match self {
315 #[cfg(feature = "postgres")]
316 DbClient::Postgres(c) => get_current_database(c).await,
317 #[cfg(feature = "mysql")]
318 DbClient::Mysql(pool) => {
319 use mysql_async::prelude::*;
320 let mut conn = pool.get_conn().await?;
321 let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
323 match db.flatten() {
324 Some(name) => Ok(name),
325 None => Err(WaypointError::ConfigError(
326 "MySQL connection has no current database (none selected in URL)".into(),
327 )),
328 }
329 }
330 }
331 }
332
333 pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
340 match self.dialect_kind() {
341 DialectKind::Postgres => Ok(configured.to_string()),
342 DialectKind::Mysql => {
343 if configured == "public" {
344 self.current_database().await
345 } else {
346 Ok(configured.to_string())
347 }
348 }
349 }
350 }
351
352 pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
361 match self {
362 #[cfg(feature = "postgres")]
363 DbClient::Postgres(c) => execute_raw(c, sql).await,
364 #[cfg(feature = "mysql")]
365 DbClient::Mysql(pool) => {
366 use mysql_async::prelude::*;
367 let start = std::time::Instant::now();
368 let mut conn = pool.get_conn().await?;
369 for stmt in crate::sql_parser::split_mysql_statements(sql) {
370 conn.query_drop(&stmt).await?;
371 }
372 Ok(start.elapsed().as_millis() as i32)
373 }
374 }
375 }
376
377 pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
386 match self {
387 #[cfg(feature = "postgres")]
388 DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
389 #[cfg(feature = "mysql")]
390 DbClient::Mysql(_) => self.execute_raw(sql).await,
391 }
392 }
393}
394
395pub async fn connect_for_url(
405 conn_string: &str,
406 #[cfg_attr(not(feature = "postgres"), allow(unused_variables))]
407 config: &crate::config::WaypointConfig,
408) -> Result<DbClient> {
409 let kind = DialectKind::from_url(conn_string).unwrap_or(config.database.engine);
410 match kind {
411 #[cfg(feature = "postgres")]
412 DialectKind::Postgres => {
413 let client = connect_with_full_config(
414 conn_string,
415 &config.database.ssl_mode,
416 config.database.connect_retries,
417 config.database.connect_timeout_secs,
418 config.database.statement_timeout_secs,
419 config.database.keepalive_secs,
420 )
421 .await?;
422 Ok(DbClient::with_postgres(client))
423 }
424 #[cfg(not(feature = "postgres"))]
425 DialectKind::Postgres => Err(WaypointError::ConfigError(
426 "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
427 )),
428 #[cfg(feature = "mysql")]
429 DialectKind::Mysql => {
430 let pool = mysql_async::Pool::from_url(conn_string).map_err(|e| {
431 WaypointError::ConfigError(format!("Invalid MySQL connection URL: {}", e))
432 })?;
433 Ok(DbClient::with_mysql(pool))
434 }
435 #[cfg(not(feature = "mysql"))]
436 DialectKind::Mysql => Err(WaypointError::ConfigError(
437 "MySQL support is not compiled in (enable the `mysql` feature)".into(),
438 )),
439 }
440}
441
442#[cfg(feature = "mysql")]
462fn mysql_lock_key(schema: &str, table_name: &str) -> String {
463 let full = format!("waypoint_{}_{}", schema, table_name);
464 if full.len() <= 64 {
465 full
466 } else {
467 format!("waypoint_{:08x}", crc32fast::hash(full.as_bytes()))
468 }
469}
470
471#[cfg(feature = "mysql")]
477async fn mysql_lock_scope(client: &DbClient) -> String {
478 client
479 .current_database()
480 .await
481 .unwrap_or_else(|_| "_nodb".to_string())
482}
483
484#[cfg(feature = "mysql")]
503type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
504
505#[cfg(feature = "mysql")]
506static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
507 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
508
509#[cfg(feature = "mysql")]
523fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
524 pool as *const mysql_async::Pool as usize
525}
526
527#[cfg(feature = "mysql")]
529fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
530 let registry_key = (mysql_pool_ident(pool), key.to_string());
531 match MYSQL_LOCK_CONNS.lock() {
532 Ok(mut guard) => {
533 guard.insert(registry_key, conn);
534 }
535 Err(poisoned) => {
536 poisoned.into_inner().insert(registry_key, conn);
540 }
541 }
542}
543
544#[cfg(feature = "mysql")]
546fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
547 let registry_key = (mysql_pool_ident(pool), key.to_string());
548 match MYSQL_LOCK_CONNS.lock() {
549 Ok(mut guard) => guard.remove(®istry_key),
550 Err(poisoned) => poisoned.into_inner().remove(®istry_key),
551 }
552}
553
554#[cfg(feature = "postgres")]
558fn make_rustls_config() -> rustls::ClientConfig {
559 let root_store =
560 rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
561 rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
562 rustls::crypto::ring::default_provider(),
563 ))
564 .with_safe_default_protocol_versions()
565 .unwrap()
566 .with_root_certificates(root_store)
567 .with_no_client_auth()
568}
569
570#[cfg(feature = "postgres")]
572fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
573 if let Some(db_err) = e.as_db_error() {
574 let code = db_err.code().code();
575 return code == "28P01" || code == "28000";
577 }
578 false
579}
580
581pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
587 if keepalive_secs == 0 {
588 return conn_string.to_string();
589 }
590 let lower = conn_string.to_lowercase();
591 if lower.contains("keepalives") {
592 return conn_string.to_string();
593 }
594 let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
595 if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
596 if conn_string.contains('?') {
597 format!("{}&{}", conn_string, params)
598 } else {
599 format!("{}?{}", conn_string, params)
600 }
601 } else {
602 format!(
604 "{} keepalives=1 keepalives_idle={}",
605 conn_string, keepalive_secs
606 )
607 }
608}
609
610#[cfg(feature = "postgres")]
616fn spawn_connection_task<F>(connection: F)
617where
618 F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
619 + Send
620 + 'static,
621{
622 tokio::spawn(async move {
623 if let Err(e) = connection.await {
624 log::error!("Database connection error: {}", e);
625 }
626 });
627}
628
629#[cfg(feature = "postgres")]
633async fn connect_once(
634 conn_string: &str,
635 ssl_mode: &SslMode,
636 connect_timeout_secs: u32,
637) -> std::result::Result<Client, tokio_postgres::Error> {
638 let connect_fut = async {
639 match ssl_mode {
640 SslMode::Disable => {
641 let (client, connection) =
642 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
643 spawn_connection_task(connection);
644 Ok(client)
645 }
646 SslMode::Require => {
647 let tls_config = make_rustls_config();
648 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
649 let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
650 spawn_connection_task(connection);
651 Ok(client)
652 }
653 SslMode::Prefer => {
654 let tls_config = make_rustls_config();
656 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
657 match tokio_postgres::connect(conn_string, tls).await {
658 Ok((client, connection)) => {
659 spawn_connection_task(connection);
660 Ok(client)
661 }
662 Err(_) => {
663 log::debug!("TLS connection failed, falling back to plaintext");
664 let (client, connection) =
665 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
666 spawn_connection_task(connection);
667 Ok(client)
668 }
669 }
670 }
671 }
672 };
673
674 if connect_timeout_secs > 0 {
675 match tokio::time::timeout(
676 std::time::Duration::from_secs(connect_timeout_secs as u64),
677 connect_fut,
678 )
679 .await
680 {
681 Ok(result) => result,
682 Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
683 }
684 } else {
685 connect_fut.await
686 }
687}
688
689#[cfg(feature = "postgres")]
693pub async fn connect(conn_string: &str) -> Result<Client> {
694 connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
695}
696
697#[cfg(feature = "postgres")]
702pub async fn connect_with_config(
703 conn_string: &str,
704 ssl_mode: &SslMode,
705 retries: u32,
706 connect_timeout_secs: u32,
707 statement_timeout_secs: u32,
708) -> Result<Client> {
709 connect_with_full_config(
710 conn_string,
711 ssl_mode,
712 retries,
713 connect_timeout_secs,
714 statement_timeout_secs,
715 120,
716 )
717 .await
718}
719
720#[cfg(feature = "postgres")]
722pub async fn connect_with_full_config(
723 conn_string: &str,
724 ssl_mode: &SslMode,
725 retries: u32,
726 connect_timeout_secs: u32,
727 statement_timeout_secs: u32,
728 keepalive_secs: u32,
729) -> Result<Client> {
730 let conn_string = inject_keepalive(conn_string, keepalive_secs);
731 let mut last_err = None;
732
733 for attempt in 0..=retries {
734 if attempt > 0 {
735 let base_delay = std::cmp::min(1u64 << attempt, 30);
736 let jitter_ms = fastrand::u64(0..1000);
737 let delay = std::time::Duration::from_secs(base_delay)
738 + std::time::Duration::from_millis(jitter_ms);
739 log::info!(
740 "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
741 attempt + 1,
742 retries + 1,
743 delay.as_millis() as u64
744 );
745 tokio::time::sleep(delay).await;
746 }
747
748 match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
749 Ok(client) => {
750 if attempt > 0 {
751 log::info!(
752 "Connected successfully after retry; attempt={}, max_attempts={}",
753 attempt + 1,
754 retries + 1
755 );
756 }
757
758 if statement_timeout_secs > 0 {
760 let timeout_sql =
761 format!("SET statement_timeout = '{}s'", statement_timeout_secs);
762 client.batch_execute(&timeout_sql).await?;
763 }
764
765 return Ok(client);
766 }
767 Err(e) => {
768 if is_permanent_error(&e) {
770 log::error!("Permanent connection error, not retrying: {}", e);
771 return Err(WaypointError::DatabaseError(e));
772 }
773 last_err = Some(e);
774 }
775 }
776 }
777
778 Err(WaypointError::DatabaseError(last_err.unwrap()))
779}
780
781#[cfg(feature = "postgres")]
785pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
786 let lock_id = advisory_lock_id(table_name);
787 log::info!(
788 "Acquiring advisory lock; lock_id={}, table={}",
789 lock_id,
790 table_name
791 );
792
793 client
794 .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
795 .await
796 .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
797
798 Ok(())
799}
800
801#[cfg(feature = "postgres")]
806pub async fn acquire_advisory_lock_with_timeout(
807 client: &Client,
808 table_name: &str,
809 timeout_secs: u32,
810) -> Result<()> {
811 let lock_id = advisory_lock_id(table_name);
812 log::info!(
813 "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
814 lock_id,
815 table_name,
816 timeout_secs
817 );
818
819 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
820
821 loop {
822 let row = client
823 .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
824 .await
825 .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
826
827 let acquired: bool = row.get(0);
828 if acquired {
829 return Ok(());
830 }
831
832 if std::time::Instant::now() >= deadline {
833 return Err(WaypointError::LockError(format!(
834 "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
835 timeout_secs, table_name
836 )));
837 }
838
839 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
841 }
842}
843
844#[cfg(feature = "postgres")]
846pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
847 let lock_id = advisory_lock_id(table_name);
848 log::info!(
849 "Releasing advisory lock; lock_id={}, table={}",
850 lock_id,
851 table_name
852 );
853
854 client
855 .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
856 .await
857 .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
858
859 Ok(())
860}
861
862pub fn advisory_lock_id(table_name: &str) -> i64 {
868 crc32fast::hash(table_name.as_bytes()) as i64
869}
870
871#[cfg(feature = "postgres")]
873pub async fn get_current_user(client: &Client) -> Result<String> {
874 let row = client.query_one("SELECT current_user", &[]).await?;
875 Ok(row.get::<_, String>(0))
876}
877
878#[cfg(feature = "postgres")]
880pub async fn get_current_database(client: &Client) -> Result<String> {
881 let row = client.query_one("SELECT current_database()", &[]).await?;
882 Ok(row.get::<_, String>(0))
883}
884
885#[cfg(feature = "postgres")]
888pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
889 let start = std::time::Instant::now();
890
891 client.batch_execute("BEGIN").await?;
892
893 match client.batch_execute(sql).await {
894 Ok(()) => {
895 client.batch_execute("COMMIT").await?;
896 }
897 Err(e) => {
898 if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
899 log::warn!("Failed to rollback transaction: {}", rollback_err);
900 }
901 return Err(WaypointError::DatabaseError(e));
902 }
903 }
904
905 let elapsed = start.elapsed().as_millis() as i32;
906 Ok(elapsed)
907}
908
909#[cfg(feature = "postgres")]
911pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
912 let start = std::time::Instant::now();
913 client.batch_execute(sql).await?;
914 let elapsed = start.elapsed().as_millis() as i32;
915 Ok(elapsed)
916}
917
918pub fn is_transient_error(e: &WaypointError) -> bool {
923 match e {
924 #[cfg(feature = "postgres")]
925 WaypointError::DatabaseError(pg_err) => {
926 if pg_err.is_closed() {
928 return true;
929 }
930 if let Some(db_err) = pg_err.as_db_error() {
932 let code = db_err.code().code();
933 return matches!(
937 code,
938 "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
939 );
940 }
941 let msg = pg_err.to_string().to_lowercase();
943 msg.contains("connection reset")
944 || msg.contains("broken pipe")
945 || msg.contains("connection closed")
946 || msg.contains("unexpected eof")
947 }
948 #[cfg(feature = "mysql")]
949 WaypointError::MysqlError(my_err) => {
950 let msg = my_err.to_string().to_lowercase();
954 msg.contains("connection reset")
955 || msg.contains("broken pipe")
956 || msg.contains("connection closed")
957 || msg.contains("server has gone away")
958 || msg.contains("lost connection")
959 || msg.contains("io error")
960 }
961 WaypointError::ConnectionLost { .. } => true,
962 _ => false,
963 }
964}
965
966#[cfg(feature = "postgres")]
968pub async fn check_connection(client: &Client) -> Result<()> {
969 client
970 .simple_query("")
971 .await
972 .map_err(|e| WaypointError::ConnectionLost {
973 operation: "health check".to_string(),
974 detail: e.to_string(),
975 })?;
976 Ok(())
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982
983 #[test]
986 fn test_inject_keepalive_url_style() {
987 let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
988 assert_eq!(
989 result,
990 "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
991 );
992 }
993
994 #[test]
995 fn test_inject_keepalive_url_with_existing_params() {
996 let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
997 assert_eq!(
998 result,
999 "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_inject_keepalive_kv_style() {
1005 let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
1006 assert_eq!(
1007 result,
1008 "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_inject_keepalive_zero_disables() {
1014 let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
1015 assert_eq!(result, "postgres://user:pass@localhost/db");
1016 }
1017
1018 #[test]
1019 fn test_inject_keepalive_already_present() {
1020 let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
1021 assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
1022 }
1023
1024 #[test]
1027 fn test_transient_error_connection_lost() {
1028 let err = WaypointError::ConnectionLost {
1029 operation: "test".to_string(),
1030 detail: "gone".to_string(),
1031 };
1032 assert!(is_transient_error(&err));
1033 }
1034
1035 #[test]
1036 fn test_transient_error_config_is_not_transient() {
1037 let err = WaypointError::ConfigError("bad config".to_string());
1038 assert!(!is_transient_error(&err));
1039 }
1040
1041 #[test]
1042 fn test_transient_error_migration_failed_is_not_transient() {
1043 let err = WaypointError::MigrationFailed {
1044 script: "V1__test.sql".to_string(),
1045 reason: "syntax error".to_string(),
1046 };
1047 assert!(!is_transient_error(&err));
1048 }
1049
1050 #[test]
1051 fn test_advisory_lock_id_stability() {
1052 let id1 = advisory_lock_id("waypoint_schema_history");
1054 let id2 = advisory_lock_id("waypoint_schema_history");
1055 assert_eq!(id1, id2);
1056 let id3 = advisory_lock_id("other_table");
1058 assert_ne!(id1, id3);
1059 }
1060
1061 #[test]
1062 fn test_transient_error_lock_error_is_not_transient() {
1063 let err = WaypointError::LockError("lock failed".to_string());
1064 assert!(!is_transient_error(&err));
1065 }
1066
1067 #[test]
1068 fn test_transient_error_io_error_is_not_transient() {
1069 let err = WaypointError::IoError(std::io::Error::new(
1070 std::io::ErrorKind::NotFound,
1071 "file not found",
1072 ));
1073 assert!(!is_transient_error(&err));
1074 }
1075
1076 #[test]
1077 fn test_validate_identifier_valid() {
1078 assert!(validate_identifier("users").is_ok());
1079 assert!(validate_identifier("my_table").is_ok());
1080 assert!(validate_identifier("Table123").is_ok());
1081 assert!(validate_identifier("a").is_ok());
1082 }
1083
1084 #[test]
1085 fn test_validate_identifier_invalid() {
1086 assert!(validate_identifier("").is_err());
1087 assert!(validate_identifier("my-table").is_err());
1088 assert!(validate_identifier("my table").is_err());
1089 assert!(validate_identifier("table.name").is_err());
1090 assert!(validate_identifier("table;drop").is_err());
1091 }
1092
1093 #[test]
1094 fn test_quote_ident_simple() {
1095 assert_eq!(quote_ident("users"), "\"users\"");
1096 }
1097
1098 #[test]
1099 fn test_quote_ident_embedded_quotes() {
1100 assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1101 }
1102
1103 #[test]
1104 fn test_quote_ident_empty() {
1105 assert_eq!(quote_ident(""), "\"\"");
1106 }
1107
1108 #[test]
1109 fn test_inject_keepalive_postgresql_prefix() {
1110 let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1111 assert_eq!(
1112 result,
1113 "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1114 );
1115 }
1116
1117 #[cfg(feature = "mysql")]
1118 #[test]
1119 fn mysql_lock_key_is_scoped_per_database() {
1120 let a = mysql_lock_key("app_prod", "waypoint_schema_history");
1124 let b = mysql_lock_key("app_staging", "waypoint_schema_history");
1125 assert_ne!(a, b);
1126 assert_eq!(a, "waypoint_app_prod_waypoint_schema_history");
1127 }
1128
1129 #[cfg(feature = "mysql")]
1130 #[test]
1131 fn mysql_lock_key_respects_the_64_char_limit() {
1132 let long_db = "d".repeat(60);
1133 let long_tbl = "t".repeat(60);
1134 let k = mysql_lock_key(&long_db, &long_tbl);
1135 assert!(
1136 k.len() <= 64,
1137 "GET_LOCK names are capped at 64: {}",
1138 k.len()
1139 );
1140 }
1141
1142 #[cfg(feature = "mysql")]
1143 #[test]
1144 fn mysql_lock_key_does_not_collide_after_shortening() {
1145 let prefix = "x".repeat(60);
1148 let a = mysql_lock_key(&prefix, "alpha");
1149 let b = mysql_lock_key(&prefix, "beta");
1150 assert!(a.len() <= 64 && b.len() <= 64);
1151 assert_ne!(a, b, "distinct tables collapsed onto one lock key");
1152 }
1153
1154 #[cfg(feature = "mysql")]
1155 #[test]
1156 fn mysql_lock_key_is_stable() {
1157 assert_eq!(mysql_lock_key("db", "tbl"), mysql_lock_key("db", "tbl"));
1160 }
1161}