1use std::time::Duration;
4
5use anyhow::{Context, Result};
6use sqlx::{
7 migrate::Migrator,
8 postgres::{PgConnectOptions, PgPoolOptions},
9 Connection, PgConnection, PgPool,
10};
11
12use crate::config::Config;
13
14pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
16
17const STARTUP_WAIT: Duration = Duration::from_secs(20);
20
21pub fn session_options(options: PgConnectOptions) -> PgConnectOptions {
36 options.options([("jit", "off"), ("plan_cache_mode", "force_custom_plan")])
37}
38
39pub async fn connect(config: &Config) -> Result<PgPool> {
40 wait_for_database(config).await?;
41 PgPoolOptions::new()
42 .max_connections(config.db_max_connections)
43 .acquire_timeout(Duration::from_secs(10))
44 .connect_with(session_options(config.database.clone()))
45 .await
46 .with_context(|| {
47 format!(
48 "cannot connect to PostgreSQL at {}",
49 config.database_display
50 )
51 })
52}
53
54async fn wait_for_database(config: &Config) -> Result<()> {
57 let started = std::time::Instant::now();
58 let mut attempt = 0u32;
59 loop {
60 attempt += 1;
61 let probe = tokio::time::timeout(
62 Duration::from_secs(5),
63 PgConnection::connect_with(&config.database),
64 )
65 .await;
66 let error = match probe {
67 Ok(Ok(conn)) => {
68 let _ = conn.close().await;
69 return Ok(());
70 }
71 Ok(Err(e)) => e.to_string(),
72 Err(_) => "connection attempt timed out".to_string(),
73 };
74 let permanent = error.contains("password authentication failed")
76 || error.contains("does not exist")
77 || error.contains("no pg_hba.conf entry");
78 if permanent || started.elapsed() >= STARTUP_WAIT {
79 anyhow::bail!(
80 "cannot connect to PostgreSQL at {}: {error}\n\
81 hint: start the development database with `docker compose up -d db` \
82 or set TEAMCRAFT_DATABASE_URL (see docs: Configuration)",
83 config.database_display
84 );
85 }
86 if attempt == 1 {
87 tracing::warn!(
88 database = %config.database_display,
89 %error,
90 "database not reachable yet, retrying for up to {}s (start it with `docker compose up -d db`)",
91 STARTUP_WAIT.as_secs()
92 );
93 }
94 tokio::time::sleep(Duration::from_secs(1)).await;
95 }
96}
97
98const EDITED_BEFORE_RELEASE: [i64; 5] = [2, 3, 5, 11, 12];
103
104pub const MIN_POSTGRES_MAJOR: i64 = 16;
108
109fn check_server_version(version_num: &str) -> Result<()> {
111 let num: i64 = version_num
112 .trim()
113 .parse()
114 .with_context(|| format!("unexpected PostgreSQL version number '{version_num}'"))?;
115 let major = num / 10_000;
116 if major < MIN_POSTGRES_MAJOR {
117 anyhow::bail!(
118 "TeamCraft needs PostgreSQL {MIN_POSTGRES_MAJOR} or newer, but the server runs \
119 PostgreSQL {major} – upgrade the database server (pg_upgrade or dump and restore)"
120 );
121 }
122 Ok(())
123}
124
125pub async fn migrate(pool: &PgPool) -> Result<()> {
126 let version: String = sqlx::query_scalar("SHOW server_version_num")
127 .fetch_one(pool)
128 .await
129 .context("reading the PostgreSQL version failed")?;
130 check_server_version(&version)?;
131 accept_edits_before_release(pool)
132 .await
133 .context("updating the checksums of edited migrations failed")?;
134 let mut conn = pool
140 .acquire()
141 .await
142 .context("connecting for the migrations failed")?;
143 let result = MIGRATOR.run(&mut *conn).await;
144 if result.is_err() {
145 let _ = conn.detach().close().await;
146 }
147 result.context("running database migrations failed")
148}
149
150async fn accept_edits_before_release(pool: &PgPool) -> Result<()> {
151 let table: Option<String> = sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations')::text")
152 .fetch_one(pool)
153 .await?;
154 if table.is_none() {
155 return Ok(()); }
157 for migration in MIGRATOR
158 .iter()
159 .filter(|m| EDITED_BEFORE_RELEASE.contains(&m.version))
160 {
161 let updated = sqlx::query(
162 "UPDATE _sqlx_migrations SET checksum = $1 \
163 WHERE version = $2 AND success AND checksum <> $1",
164 )
165 .bind(migration.checksum.as_ref())
166 .bind(migration.version)
167 .execute(pool)
168 .await?
169 .rows_affected();
170 if updated > 0 {
171 tracing::info!(
172 version = migration.version,
173 "accepted the edited text of an applied migration"
174 );
175 }
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
188 fn migration_numbers_are_unique() {
189 let mut seen = std::collections::HashMap::new();
190 for m in MIGRATOR.iter() {
191 if let Some(other) = seen.insert(m.version, m.description.clone()) {
192 panic!(
193 "migration number {:04} is used twice: '{}' and '{}' – renumber the newer one",
194 m.version, other, m.description
195 );
196 }
197 }
198 }
199
200 #[test]
201 fn supported_postgres_versions() {
202 for ok in ["160000", "160009", "170004", "180001\n", "190000"] {
203 assert!(check_server_version(ok).is_ok(), "{ok}");
204 }
205 for old in ["140013", "150010", "90624"] {
206 let err = check_server_version(old).unwrap_err().to_string();
207 assert!(err.contains("PostgreSQL 16 or newer"), "{err}");
208 }
209 assert!(check_server_version("17.4").is_err());
210 }
211}