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(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(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(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")]
448fn mysql_lock_key(table_name: &str) -> String {
449 let mut k = format!("waypoint_{}", table_name);
450 if k.len() > 64 {
451 k.truncate(64);
452 }
453 k
454}
455
456#[cfg(feature = "mysql")]
475type MysqlLockRegistry = std::collections::HashMap<(usize, String), mysql_async::Conn>;
476
477#[cfg(feature = "mysql")]
478static MYSQL_LOCK_CONNS: std::sync::LazyLock<std::sync::Mutex<MysqlLockRegistry>> =
479 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
480
481#[cfg(feature = "mysql")]
495fn mysql_pool_ident(pool: &mysql_async::Pool) -> usize {
496 pool as *const mysql_async::Pool as usize
497}
498
499#[cfg(feature = "mysql")]
501fn park_lock_conn(pool: &mysql_async::Pool, key: &str, conn: mysql_async::Conn) {
502 let registry_key = (mysql_pool_ident(pool), key.to_string());
503 match MYSQL_LOCK_CONNS.lock() {
504 Ok(mut guard) => {
505 guard.insert(registry_key, conn);
506 }
507 Err(poisoned) => {
508 poisoned.into_inner().insert(registry_key, conn);
512 }
513 }
514}
515
516#[cfg(feature = "mysql")]
518fn unpark_lock_conn(pool: &mysql_async::Pool, key: &str) -> Option<mysql_async::Conn> {
519 let registry_key = (mysql_pool_ident(pool), key.to_string());
520 match MYSQL_LOCK_CONNS.lock() {
521 Ok(mut guard) => guard.remove(®istry_key),
522 Err(poisoned) => poisoned.into_inner().remove(®istry_key),
523 }
524}
525
526#[cfg(feature = "postgres")]
530fn make_rustls_config() -> rustls::ClientConfig {
531 let root_store =
532 rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
533 rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
534 rustls::crypto::ring::default_provider(),
535 ))
536 .with_safe_default_protocol_versions()
537 .unwrap()
538 .with_root_certificates(root_store)
539 .with_no_client_auth()
540}
541
542#[cfg(feature = "postgres")]
544fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
545 if let Some(db_err) = e.as_db_error() {
546 let code = db_err.code().code();
547 return code == "28P01" || code == "28000";
549 }
550 false
551}
552
553pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
559 if keepalive_secs == 0 {
560 return conn_string.to_string();
561 }
562 let lower = conn_string.to_lowercase();
563 if lower.contains("keepalives") {
564 return conn_string.to_string();
565 }
566 let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
567 if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
568 if conn_string.contains('?') {
569 format!("{}&{}", conn_string, params)
570 } else {
571 format!("{}?{}", conn_string, params)
572 }
573 } else {
574 format!(
576 "{} keepalives=1 keepalives_idle={}",
577 conn_string, keepalive_secs
578 )
579 }
580}
581
582#[cfg(feature = "postgres")]
588fn spawn_connection_task<F>(connection: F)
589where
590 F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
591 + Send
592 + 'static,
593{
594 tokio::spawn(async move {
595 if let Err(e) = connection.await {
596 log::error!("Database connection error: {}", e);
597 }
598 });
599}
600
601#[cfg(feature = "postgres")]
605async fn connect_once(
606 conn_string: &str,
607 ssl_mode: &SslMode,
608 connect_timeout_secs: u32,
609) -> std::result::Result<Client, tokio_postgres::Error> {
610 let connect_fut = async {
611 match ssl_mode {
612 SslMode::Disable => {
613 let (client, connection) =
614 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
615 spawn_connection_task(connection);
616 Ok(client)
617 }
618 SslMode::Require => {
619 let tls_config = make_rustls_config();
620 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
621 let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
622 spawn_connection_task(connection);
623 Ok(client)
624 }
625 SslMode::Prefer => {
626 let tls_config = make_rustls_config();
628 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
629 match tokio_postgres::connect(conn_string, tls).await {
630 Ok((client, connection)) => {
631 spawn_connection_task(connection);
632 Ok(client)
633 }
634 Err(_) => {
635 log::debug!("TLS connection failed, falling back to plaintext");
636 let (client, connection) =
637 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
638 spawn_connection_task(connection);
639 Ok(client)
640 }
641 }
642 }
643 }
644 };
645
646 if connect_timeout_secs > 0 {
647 match tokio::time::timeout(
648 std::time::Duration::from_secs(connect_timeout_secs as u64),
649 connect_fut,
650 )
651 .await
652 {
653 Ok(result) => result,
654 Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
655 }
656 } else {
657 connect_fut.await
658 }
659}
660
661#[cfg(feature = "postgres")]
665pub async fn connect(conn_string: &str) -> Result<Client> {
666 connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
667}
668
669#[cfg(feature = "postgres")]
674pub async fn connect_with_config(
675 conn_string: &str,
676 ssl_mode: &SslMode,
677 retries: u32,
678 connect_timeout_secs: u32,
679 statement_timeout_secs: u32,
680) -> Result<Client> {
681 connect_with_full_config(
682 conn_string,
683 ssl_mode,
684 retries,
685 connect_timeout_secs,
686 statement_timeout_secs,
687 120,
688 )
689 .await
690}
691
692#[cfg(feature = "postgres")]
694pub async fn connect_with_full_config(
695 conn_string: &str,
696 ssl_mode: &SslMode,
697 retries: u32,
698 connect_timeout_secs: u32,
699 statement_timeout_secs: u32,
700 keepalive_secs: u32,
701) -> Result<Client> {
702 let conn_string = inject_keepalive(conn_string, keepalive_secs);
703 let mut last_err = None;
704
705 for attempt in 0..=retries {
706 if attempt > 0 {
707 let base_delay = std::cmp::min(1u64 << attempt, 30);
708 let jitter_ms = fastrand::u64(0..1000);
709 let delay = std::time::Duration::from_secs(base_delay)
710 + std::time::Duration::from_millis(jitter_ms);
711 log::info!(
712 "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
713 attempt + 1,
714 retries + 1,
715 delay.as_millis() as u64
716 );
717 tokio::time::sleep(delay).await;
718 }
719
720 match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
721 Ok(client) => {
722 if attempt > 0 {
723 log::info!(
724 "Connected successfully after retry; attempt={}, max_attempts={}",
725 attempt + 1,
726 retries + 1
727 );
728 }
729
730 if statement_timeout_secs > 0 {
732 let timeout_sql =
733 format!("SET statement_timeout = '{}s'", statement_timeout_secs);
734 client.batch_execute(&timeout_sql).await?;
735 }
736
737 return Ok(client);
738 }
739 Err(e) => {
740 if is_permanent_error(&e) {
742 log::error!("Permanent connection error, not retrying: {}", e);
743 return Err(WaypointError::DatabaseError(e));
744 }
745 last_err = Some(e);
746 }
747 }
748 }
749
750 Err(WaypointError::DatabaseError(last_err.unwrap()))
751}
752
753#[cfg(feature = "postgres")]
757pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
758 let lock_id = advisory_lock_id(table_name);
759 log::info!(
760 "Acquiring advisory lock; lock_id={}, table={}",
761 lock_id,
762 table_name
763 );
764
765 client
766 .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
767 .await
768 .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
769
770 Ok(())
771}
772
773#[cfg(feature = "postgres")]
778pub async fn acquire_advisory_lock_with_timeout(
779 client: &Client,
780 table_name: &str,
781 timeout_secs: u32,
782) -> Result<()> {
783 let lock_id = advisory_lock_id(table_name);
784 log::info!(
785 "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
786 lock_id,
787 table_name,
788 timeout_secs
789 );
790
791 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
792
793 loop {
794 let row = client
795 .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
796 .await
797 .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
798
799 let acquired: bool = row.get(0);
800 if acquired {
801 return Ok(());
802 }
803
804 if std::time::Instant::now() >= deadline {
805 return Err(WaypointError::LockError(format!(
806 "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
807 timeout_secs, table_name
808 )));
809 }
810
811 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
813 }
814}
815
816#[cfg(feature = "postgres")]
818pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
819 let lock_id = advisory_lock_id(table_name);
820 log::info!(
821 "Releasing advisory lock; lock_id={}, table={}",
822 lock_id,
823 table_name
824 );
825
826 client
827 .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
828 .await
829 .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
830
831 Ok(())
832}
833
834pub fn advisory_lock_id(table_name: &str) -> i64 {
840 crc32fast::hash(table_name.as_bytes()) as i64
841}
842
843#[cfg(feature = "postgres")]
845pub async fn get_current_user(client: &Client) -> Result<String> {
846 let row = client.query_one("SELECT current_user", &[]).await?;
847 Ok(row.get::<_, String>(0))
848}
849
850#[cfg(feature = "postgres")]
852pub async fn get_current_database(client: &Client) -> Result<String> {
853 let row = client.query_one("SELECT current_database()", &[]).await?;
854 Ok(row.get::<_, String>(0))
855}
856
857#[cfg(feature = "postgres")]
860pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
861 let start = std::time::Instant::now();
862
863 client.batch_execute("BEGIN").await?;
864
865 match client.batch_execute(sql).await {
866 Ok(()) => {
867 client.batch_execute("COMMIT").await?;
868 }
869 Err(e) => {
870 if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
871 log::warn!("Failed to rollback transaction: {}", rollback_err);
872 }
873 return Err(WaypointError::DatabaseError(e));
874 }
875 }
876
877 let elapsed = start.elapsed().as_millis() as i32;
878 Ok(elapsed)
879}
880
881#[cfg(feature = "postgres")]
883pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
884 let start = std::time::Instant::now();
885 client.batch_execute(sql).await?;
886 let elapsed = start.elapsed().as_millis() as i32;
887 Ok(elapsed)
888}
889
890pub fn is_transient_error(e: &WaypointError) -> bool {
895 match e {
896 #[cfg(feature = "postgres")]
897 WaypointError::DatabaseError(pg_err) => {
898 if pg_err.is_closed() {
900 return true;
901 }
902 if let Some(db_err) = pg_err.as_db_error() {
904 let code = db_err.code().code();
905 return matches!(
909 code,
910 "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
911 );
912 }
913 let msg = pg_err.to_string().to_lowercase();
915 msg.contains("connection reset")
916 || msg.contains("broken pipe")
917 || msg.contains("connection closed")
918 || msg.contains("unexpected eof")
919 }
920 #[cfg(feature = "mysql")]
921 WaypointError::MysqlError(my_err) => {
922 let msg = my_err.to_string().to_lowercase();
926 msg.contains("connection reset")
927 || msg.contains("broken pipe")
928 || msg.contains("connection closed")
929 || msg.contains("server has gone away")
930 || msg.contains("lost connection")
931 || msg.contains("io error")
932 }
933 WaypointError::ConnectionLost { .. } => true,
934 _ => false,
935 }
936}
937
938#[cfg(feature = "postgres")]
940pub async fn check_connection(client: &Client) -> Result<()> {
941 client
942 .simple_query("")
943 .await
944 .map_err(|e| WaypointError::ConnectionLost {
945 operation: "health check".to_string(),
946 detail: e.to_string(),
947 })?;
948 Ok(())
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 #[test]
958 fn test_inject_keepalive_url_style() {
959 let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
960 assert_eq!(
961 result,
962 "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
963 );
964 }
965
966 #[test]
967 fn test_inject_keepalive_url_with_existing_params() {
968 let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
969 assert_eq!(
970 result,
971 "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
972 );
973 }
974
975 #[test]
976 fn test_inject_keepalive_kv_style() {
977 let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
978 assert_eq!(
979 result,
980 "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
981 );
982 }
983
984 #[test]
985 fn test_inject_keepalive_zero_disables() {
986 let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
987 assert_eq!(result, "postgres://user:pass@localhost/db");
988 }
989
990 #[test]
991 fn test_inject_keepalive_already_present() {
992 let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
993 assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
994 }
995
996 #[test]
999 fn test_transient_error_connection_lost() {
1000 let err = WaypointError::ConnectionLost {
1001 operation: "test".to_string(),
1002 detail: "gone".to_string(),
1003 };
1004 assert!(is_transient_error(&err));
1005 }
1006
1007 #[test]
1008 fn test_transient_error_config_is_not_transient() {
1009 let err = WaypointError::ConfigError("bad config".to_string());
1010 assert!(!is_transient_error(&err));
1011 }
1012
1013 #[test]
1014 fn test_transient_error_migration_failed_is_not_transient() {
1015 let err = WaypointError::MigrationFailed {
1016 script: "V1__test.sql".to_string(),
1017 reason: "syntax error".to_string(),
1018 };
1019 assert!(!is_transient_error(&err));
1020 }
1021
1022 #[test]
1023 fn test_advisory_lock_id_stability() {
1024 let id1 = advisory_lock_id("waypoint_schema_history");
1026 let id2 = advisory_lock_id("waypoint_schema_history");
1027 assert_eq!(id1, id2);
1028 let id3 = advisory_lock_id("other_table");
1030 assert_ne!(id1, id3);
1031 }
1032
1033 #[test]
1034 fn test_transient_error_lock_error_is_not_transient() {
1035 let err = WaypointError::LockError("lock failed".to_string());
1036 assert!(!is_transient_error(&err));
1037 }
1038
1039 #[test]
1040 fn test_transient_error_io_error_is_not_transient() {
1041 let err = WaypointError::IoError(std::io::Error::new(
1042 std::io::ErrorKind::NotFound,
1043 "file not found",
1044 ));
1045 assert!(!is_transient_error(&err));
1046 }
1047
1048 #[test]
1049 fn test_validate_identifier_valid() {
1050 assert!(validate_identifier("users").is_ok());
1051 assert!(validate_identifier("my_table").is_ok());
1052 assert!(validate_identifier("Table123").is_ok());
1053 assert!(validate_identifier("a").is_ok());
1054 }
1055
1056 #[test]
1057 fn test_validate_identifier_invalid() {
1058 assert!(validate_identifier("").is_err());
1059 assert!(validate_identifier("my-table").is_err());
1060 assert!(validate_identifier("my table").is_err());
1061 assert!(validate_identifier("table.name").is_err());
1062 assert!(validate_identifier("table;drop").is_err());
1063 }
1064
1065 #[test]
1066 fn test_quote_ident_simple() {
1067 assert_eq!(quote_ident("users"), "\"users\"");
1068 }
1069
1070 #[test]
1071 fn test_quote_ident_embedded_quotes() {
1072 assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
1073 }
1074
1075 #[test]
1076 fn test_quote_ident_empty() {
1077 assert_eq!(quote_ident(""), "\"\"");
1078 }
1079
1080 #[test]
1081 fn test_inject_keepalive_postgresql_prefix() {
1082 let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
1083 assert_eq!(
1084 result,
1085 "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
1086 );
1087 }
1088}