use diesel::RunQueryDsl;
use diesel::migration::{Migration, MigrationSource};
use diesel::pg::Pg;
use diesel_migrations::{FileBasedMigrations, HarnessWithOutput, MigrationHarness};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::Path;
pub use diesel_migrations::EmbeddedMigrations;
pub use diesel_migrations::embed_migrations;
pub const FRAMEWORK_MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Debug)]
pub struct MigrationResult {
pub applied: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MigrationError {
#[error("failed to connect to database: {0}")]
Connection(String),
#[error("migration failed: {0}")]
Migration(String),
#[error(
"migration advisory lock not acquired within {timeout_secs}s; \
another process may still be running migrations"
)]
LockTimeout {
timeout_secs: u64,
},
}
macro_rules! with_migration_connection {
($url:expr, |$conn:ident| $body:expr) => {
match crate::db::establish_migration_connection($url)
.map_err(|e| MigrationError::Connection(e.to_string()))?
{
crate::db::MigrationConnection::Native(mut native) => {
let $conn = &mut native;
$body
}
crate::db::MigrationConnection::Rustls { mut conn, runtime } => {
let result = {
let $conn = &mut conn;
$body
};
drop(conn);
drop(runtime);
result
}
}
};
}
pub const MIGRATION_ADVISORY_LOCK_KEY: i64 = 0x6175_746E_5F6D_6967_u64.cast_signed();
pub const DEFAULT_LOCK_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(diesel::QueryableByName)]
struct AdvisoryLockRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
acquired: bool,
}
#[derive(diesel::QueryableByName)]
struct AdvisoryUnlockRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
released: bool,
}
#[derive(diesel::QueryableByName)]
struct AppliedMigrationVersion {
#[diesel(sql_type = diesel::sql_types::Text)]
version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReplicaMigrationReadiness {
Ready,
Stale {
primary_latest: Option<String>,
replica_latest: Option<String>,
},
Unknown(String),
}
impl ReplicaMigrationReadiness {
#[must_use]
pub(crate) const fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
#[must_use]
pub(crate) fn detail(&self) -> Option<String> {
match self {
Self::Ready => None,
Self::Stale {
primary_latest,
replica_latest,
} => Some(format!(
"replica migrations lag primary (primary_latest={}, replica_latest={})",
primary_latest.as_deref().unwrap_or("<none>"),
replica_latest.as_deref().unwrap_or("<none>")
)),
Self::Unknown(error) => Some(format!("replica migration readiness unknown: {error}")),
}
}
}
pub(crate) struct EmbeddedMigrationsRef<'a>(pub &'a EmbeddedMigrations);
impl<DB: diesel::backend::Backend> diesel::migration::MigrationSource<DB>
for EmbeddedMigrationsRef<'_>
{
fn migrations(
&self,
) -> diesel::migration::Result<Vec<Box<dyn diesel::migration::Migration<DB>>>> {
diesel::migration::MigrationSource::<DB>::migrations(self.0)
}
}
pub fn run_pending(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::pg::Pg>,
) -> Result<MigrationResult, MigrationError> {
with_migration_connection!(database_url, |conn| {
let mut harness = HarnessWithOutput::write_to_stdout(conn);
let applied = harness
.run_pending_migrations(migrations)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(MigrationResult {
applied: applied.iter().map(|m| format!("{m}")).collect(),
})
})
}
pub fn pending_migrations(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::pg::Pg>,
) -> Result<Vec<String>, MigrationError> {
with_migration_connection!(database_url, |conn| {
let pending = conn
.pending_migrations(migrations)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(pending
.iter()
.map(|m| m.name().version().to_string())
.collect())
})
}
#[cfg(feature = "sqlite")]
pub(crate) const IN_MEMORY_MIGRATION_MSG: &str = "In-memory SQLite (`:memory:` / `sqlite::memory:` / `file::memory:`, including \
`cache=shared`) cannot be used with registered startup migrations \u{2014} the \
schema is applied on a transient connection and is lost before the runtime \
pool anchors it. Use a file-backed SQLite database.";
#[cfg(feature = "sqlite")]
pub(crate) fn reject_in_memory_migrations<S>(
database_url: &str,
migrations: &S,
) -> Option<MigrationError>
where
S: diesel::migration::MigrationSource<diesel::sqlite::Sqlite>,
{
if !crate::db::sqlite_target_is_any_in_memory(database_url) {
return None;
}
let has_registered = migrations.migrations().map_or(true, |m| !m.is_empty());
if !has_registered {
return None;
}
Some(MigrationError::Migration(
IN_MEMORY_MIGRATION_MSG.to_owned(),
))
}
#[cfg(feature = "sqlite")]
pub fn run_pending_sqlite(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::sqlite::Sqlite>,
) -> Result<MigrationResult, MigrationError> {
if let Some(err) = reject_in_memory_migrations(database_url, &migrations) {
return Err(err);
}
let mut conn = crate::db::establish_sqlite_migration_connection(database_url)
.map_err(|e| MigrationError::Connection(e.to_string()))?;
let mut harness = HarnessWithOutput::write_to_stdout(&mut conn);
let applied = harness
.run_pending_migrations(migrations)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(MigrationResult {
applied: applied.iter().map(|m| format!("{m}")).collect(),
})
}
#[cfg(feature = "sqlite")]
fn pending_migrations_sqlite(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::sqlite::Sqlite>,
) -> Result<Vec<String>, MigrationError> {
let mut conn = crate::db::establish_sqlite_migration_connection(database_url)
.map_err(|e| MigrationError::Connection(e.to_string()))?;
let pending = conn
.pending_migrations(migrations)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(pending
.iter()
.map(|m| m.name().version().to_string())
.collect())
}
pub(crate) fn compare_replica_migration_versions(
primary: &[String],
replica: &[String],
) -> ReplicaMigrationReadiness {
let primary_versions: std::collections::BTreeSet<_> = primary.iter().collect();
let replica_versions: std::collections::BTreeSet<_> = replica.iter().collect();
if primary_versions == replica_versions {
ReplicaMigrationReadiness::Ready
} else {
ReplicaMigrationReadiness::Stale {
primary_latest: primary_versions
.iter()
.next_back()
.map(|version| (*version).clone()),
replica_latest: replica_versions
.iter()
.next_back()
.map(|version| (*version).clone()),
}
}
}
fn applied_migration_versions(database_url: &str) -> Result<Vec<String>, MigrationError> {
with_migration_connection!(database_url, |conn| {
let rows =
diesel::sql_query("SELECT version FROM __diesel_schema_migrations ORDER BY version")
.load::<AppliedMigrationVersion>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(rows.into_iter().map(|row| row.version).collect())
})
}
pub(crate) fn check_replica_migration_readiness(
primary_url: &str,
replica_url: &str,
) -> ReplicaMigrationReadiness {
let primary = match applied_migration_versions(primary_url) {
Ok(versions) => versions,
Err(error) => return ReplicaMigrationReadiness::Unknown(error.to_string()),
};
let replica = match applied_migration_versions(replica_url) {
Ok(versions) => versions,
Err(error) => return ReplicaMigrationReadiness::Unknown(error.to_string()),
};
compare_replica_migration_versions(&primary, &replica)
}
pub(crate) async fn check_replica_migration_readiness_blocking(
primary_url: String,
replica_url: String,
) -> ReplicaMigrationReadiness {
tokio::task::spawn_blocking(move || {
check_replica_migration_readiness(&primary_url, &replica_url)
})
.await
.unwrap_or_else(|error| {
ReplicaMigrationReadiness::Unknown(format!(
"replica migration readiness task failed: {error}"
))
})
}
pub fn acquire_migration_lock(
conn: &mut diesel::PgConnection,
timeout: std::time::Duration,
) -> Result<(), MigrationError> {
acquire_migration_lock_on(conn, timeout)
}
fn acquire_migration_lock_on<C>(
conn: &mut C,
timeout: std::time::Duration,
) -> Result<(), MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
let start = std::time::Instant::now();
let poll = std::time::Duration::from_millis(500);
tracing::info!(
lock_key = MIGRATION_ADVISORY_LOCK_KEY,
timeout_secs = timeout.as_secs(),
"Acquiring migration advisory lock",
);
loop {
let acquired = diesel::sql_query("SELECT pg_try_advisory_lock($1) AS acquired")
.bind::<diesel::sql_types::BigInt, _>(MIGRATION_ADVISORY_LOCK_KEY)
.get_result::<AdvisoryLockRow>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?
.acquired;
if acquired {
tracing::info!("Migration advisory lock acquired");
return Ok(());
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(MigrationError::LockTimeout {
timeout_secs: timeout.as_secs(),
});
}
tracing::debug!(
elapsed_secs = elapsed.as_secs(),
timeout_secs = timeout.as_secs(),
"Waiting for migration advisory lock; another process may be running migrations",
);
std::thread::sleep(poll.min(timeout.saturating_sub(elapsed)));
}
}
pub fn release_migration_lock(conn: &mut diesel::PgConnection) {
release_migration_lock_on(conn);
}
fn release_migration_lock_on<C>(conn: &mut C)
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
match diesel::sql_query("SELECT pg_advisory_unlock($1) AS released")
.bind::<diesel::sql_types::BigInt, _>(MIGRATION_ADVISORY_LOCK_KEY)
.get_result::<AdvisoryUnlockRow>(conn)
{
Ok(row) if row.released => {
tracing::info!("Migration advisory lock released");
}
Ok(_) => {
tracing::warn!("Migration advisory unlock returned false: lock was not held");
}
Err(e) => {
tracing::warn!(error = %e, "Failed to release migration advisory lock");
}
}
}
pub struct MigrationLockGuard {
conn: crate::db::MigrationConnection,
}
impl std::fmt::Debug for MigrationLockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MigrationLockGuard").finish_non_exhaustive()
}
}
impl Drop for MigrationLockGuard {
fn drop(&mut self) {
match &mut self.conn {
crate::db::MigrationConnection::Native(conn) => release_migration_lock_on(conn),
crate::db::MigrationConnection::Rustls { conn, .. } => release_migration_lock_on(conn),
}
}
}
#[derive(Debug)]
pub struct RevertedMigration {
pub version: String,
pub name: String,
pub duration: std::time::Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedUserMigration {
pub version: String,
pub name: String,
pub dir: Option<std::path::PathBuf>,
}
fn framework_migration_versions() -> Result<std::collections::BTreeSet<String>, MigrationError> {
framework_migration_versions_for::<Pg>()
}
fn framework_migration_versions_for<DB>()
-> Result<std::collections::BTreeSet<String>, MigrationError>
where
DB: diesel::backend::Backend,
EmbeddedMigrations: diesel::migration::MigrationSource<DB>,
{
let mut versions = std::collections::BTreeSet::new();
for migrations in [
MigrationSource::<DB>::migrations(&FRAMEWORK_MIGRATIONS),
MigrationSource::<DB>::migrations(&crate::version_history::VERSION_HISTORY_MIGRATIONS),
MigrationSource::<DB>::migrations(
&crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS,
),
] {
let migrations = migrations.map_err(|e| MigrationError::Migration(e.to_string()))?;
versions.extend(migrations.iter().map(|m| m.name().version().to_string()));
}
Ok(versions)
}
#[must_use]
pub fn migration_checksum(up_sql: &str) -> String {
let normalised = normalise_up_sql(up_sql);
let mut hasher = Sha256::new();
hasher.update(normalised.as_bytes());
hex::encode(hasher.finalize())
}
#[must_use]
pub fn migration_checksum_bytes(up_sql: &[u8]) -> String {
let s = String::from_utf8_lossy(up_sql);
migration_checksum(&s)
}
fn normalise_up_sql(up_sql: &str) -> String {
let mut normalised = up_sql.replace("\r\n", "\n");
if normalised.contains('\r') {
normalised = normalised.replace('\r', "\n");
}
let trimmed = normalised.trim_end();
trimmed.to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChecksumState {
Ok,
Changed {
recorded: String,
actual: String,
},
Missing {
recorded: String,
},
Unrecorded,
}
#[must_use]
pub fn classify<S1, S2>(
applied: &[String],
up_sql_by_version: &HashMap<String, String, S1>,
recorded: &HashMap<String, String, S2>,
) -> Vec<(String, ChecksumState)>
where
S1: std::hash::BuildHasher,
S2: std::hash::BuildHasher,
{
applied
.iter()
.map(|version| {
let state = match (up_sql_by_version.get(version), recorded.get(version)) {
(Some(up_sql), Some(recorded_hash)) => {
let actual = migration_checksum(up_sql);
if &actual == recorded_hash {
ChecksumState::Ok
} else {
ChecksumState::Changed {
recorded: recorded_hash.clone(),
actual,
}
}
}
(None, Some(recorded_hash)) => ChecksumState::Missing {
recorded: recorded_hash.clone(),
},
(Some(_) | None, None) => ChecksumState::Unrecorded,
};
(version.clone(), state)
})
.collect()
}
pub fn validate_checksums<S1, S2>(
applied: &[String],
up_sql_by_version: &HashMap<String, String, S1>,
recorded: &HashMap<String, String, S2>,
) -> Result<(), MigrationError>
where
S1: std::hash::BuildHasher,
S2: std::hash::BuildHasher,
{
for (version, state) in classify(applied, up_sql_by_version, recorded) {
match state {
ChecksumState::Changed { recorded, actual } => {
return Err(MigrationError::Migration(format!(
"migration {version} checksum mismatch: recorded {recorded} but on-disk \
content hashes to {actual}. Migrations must never be edited after being \
applied \u{2014} add a new migration instead, or run the documented \
re-baseline command if this change was deliberate."
)));
}
ChecksumState::Missing { recorded } => {
return Err(MigrationError::Migration(format!(
"migration {version} is recorded as applied (checksum {recorded}) but its \
up.sql is missing from the source tree \u{2014} a migration must never be \
deleted or renamed after being applied; add a new migration instead."
)));
}
ChecksumState::Ok | ChecksumState::Unrecorded => {}
}
}
Ok(())
}
#[derive(diesel::QueryableByName)]
struct RecordedChecksumRow {
#[diesel(sql_type = diesel::sql_types::Text)]
version: String,
#[diesel(sql_type = diesel::sql_types::Text)]
checksum: String,
}
#[derive(diesel::QueryableByName)]
struct TableExistsRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
present: bool,
}
fn ensure_checksum_table<C>(conn: &mut C) -> Result<(), MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
diesel::sql_query(
"CREATE TABLE IF NOT EXISTS autumn_migration_checksums (\
version TEXT PRIMARY KEY, \
checksum TEXT NOT NULL, \
algorithm TEXT NOT NULL DEFAULT 'sha256', \
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()\
)",
)
.execute(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(())
}
pub fn recorded_checksums<C>(conn: &mut C) -> Result<HashMap<String, String>, MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
let present = diesel::sql_query(
"SELECT to_regclass('autumn_migration_checksums') IS NOT NULL AS present",
)
.get_result::<TableExistsRow>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?
.present;
if !present {
return Ok(HashMap::new());
}
let rows: Vec<RecordedChecksumRow> =
diesel::sql_query("SELECT version, checksum FROM autumn_migration_checksums")
.load(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(rows.into_iter().map(|r| (r.version, r.checksum)).collect())
}
pub fn record_checksum<C>(conn: &mut C, version: &str, checksum: &str) -> Result<(), MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
ensure_checksum_table(conn)?;
diesel::sql_query(
"INSERT INTO autumn_migration_checksums (version, checksum) \
VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
)
.bind::<diesel::sql_types::Text, _>(version)
.bind::<diesel::sql_types::Text, _>(checksum)
.execute(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(())
}
pub fn rebaseline_checksum<C>(
conn: &mut C,
version: &str,
checksum: &str,
) -> Result<(), MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
ensure_checksum_table(conn)?;
let prior: Vec<RecordedChecksumRow> = diesel::sql_query(
"SELECT version, checksum FROM autumn_migration_checksums WHERE version = $1",
)
.bind::<diesel::sql_types::Text, _>(version)
.load(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
let old = prior.into_iter().next().map(|r| r.checksum);
diesel::sql_query(
"INSERT INTO autumn_migration_checksums (version, checksum) VALUES ($1, $2) \
ON CONFLICT (version) DO UPDATE SET checksum = EXCLUDED.checksum, \
recorded_at = now()",
)
.bind::<diesel::sql_types::Text, _>(version)
.bind::<diesel::sql_types::Text, _>(checksum)
.execute(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
tracing::warn!(
version = %version,
old_checksum = %old.as_deref().unwrap_or("<none>"),
new_checksum = %checksum,
"Re-baselined migration checksum (escape hatch) \u{2014} the migration content \
has been declared canonical; other environments running the previous content will \
now report a mismatch."
);
Ok(())
}
pub fn delete_checksum<C>(conn: &mut C, version: &str) -> Result<(), MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
ensure_checksum_table(conn)?;
diesel::sql_query("DELETE FROM autumn_migration_checksums WHERE version = $1")
.bind::<diesel::sql_types::Text, _>(version)
.execute(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(())
}
pub fn delete_checksums<C>(conn: &mut C, versions: &[String]) -> Result<usize, MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
ensure_checksum_table(conn)?;
let mut deleted = 0usize;
for version in versions {
deleted += diesel::sql_query("DELETE FROM autumn_migration_checksums WHERE version = $1")
.bind::<diesel::sql_types::Text, _>(version)
.execute(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
}
Ok(deleted)
}
pub fn record_checksums<C, S>(
conn: &mut C,
applied: &[String],
up_sql_by_version: &HashMap<String, String, S>,
) -> Result<usize, MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
S: std::hash::BuildHasher,
{
ensure_checksum_table(conn)?;
let existing = recorded_checksums(conn)?;
let mut recorded = 0usize;
for version in applied {
if existing.contains_key(version) {
continue;
}
let Some(up_sql) = up_sql_by_version.get(version) else {
continue;
};
record_checksum(conn, version, &migration_checksum(up_sql))?;
recorded += 1;
}
Ok(recorded)
}
pub fn read_up_sql_by_version(
migrations_dir: &Path,
) -> Result<HashMap<String, String>, MigrationError> {
let source = FileBasedMigrations::from_path(migrations_dir)
.map_err(|e| MigrationError::Migration(format!("failed to read migrations dir: {e}")))?;
let migrations: Vec<Box<dyn Migration<Pg>>> = source
.migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?;
let mut out = HashMap::new();
for migration in &migrations {
let version = migration.name().version().to_string();
let dir = migrations_dir.join(migration.name().to_string());
let up = dir.join("up.sql");
if let Ok(content) = std::fs::read_to_string(&up) {
out.insert(version, content);
}
}
Ok(out)
}
pub fn validate_recorded_checksums_against_dir(
database_url: &str,
migrations_dir: &Path,
) -> Result<(), MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
with_migration_connection!(database_url, |conn| {
let recorded = recorded_checksums(conn)?;
let applied = load_applied_versions_lenient(conn)?;
validate_checksums(&applied, &up_by_version, &recorded)
})
}
fn load_applied_versions_lenient<C>(conn: &mut C) -> Result<Vec<String>, MigrationError>
where
C: diesel::connection::LoadConnection<Backend = Pg>,
{
let present = diesel::sql_query(
"SELECT to_regclass('__diesel_schema_migrations') IS NOT NULL AS present",
)
.get_result::<TableExistsRow>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?
.present;
if !present {
return Ok(Vec::new());
}
let rows = diesel::sql_query("SELECT version FROM __diesel_schema_migrations ORDER BY version")
.load::<AppliedMigrationVersion>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.version).collect())
}
pub fn record_checksums_from_dir(
database_url: &str,
migrations_dir: &Path,
) -> Result<usize, MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
with_migration_connection!(database_url, |conn| {
let applied = load_applied_versions_lenient(conn)?;
record_checksums(conn, &applied, &up_by_version)
})
}
pub fn rebaseline_checksum_from_dir(
database_url: &str,
migrations_dir: &Path,
version: &str,
) -> Result<(), MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
let Some(up_sql) = up_by_version.get(version) else {
return Err(MigrationError::Migration(format!(
"cannot re-baseline {version}: its up.sql was not found in {}",
migrations_dir.display()
)));
};
let new_checksum = migration_checksum(up_sql);
with_migration_connection!(database_url, |conn| {
let is_applied =
!diesel::sql_query("SELECT version FROM __diesel_schema_migrations WHERE version = $1")
.bind::<diesel::sql_types::Text, _>(version)
.load::<AppliedMigrationVersion>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?
.is_empty();
if !is_applied {
return Err(MigrationError::Migration(format!(
"cannot re-baseline {version}: it is not a currently applied migration"
)));
}
rebaseline_checksum(conn, version, &new_checksum)
})
}
pub fn record_checksums_from_dir_locked(
database_url: &str,
migrations_dir: &Path,
wait_timeout: Option<std::time::Duration>,
) -> Result<usize, MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
let timeout = wait_timeout.unwrap_or(DEFAULT_LOCK_WAIT_TIMEOUT);
with_migration_connection!(database_url, |conn| {
acquire_migration_lock_on(conn, timeout)?;
let result: Result<usize, MigrationError> = (|| {
let applied = load_applied_versions_lenient(conn)?;
record_checksums(conn, &applied, &up_by_version)
})();
release_migration_lock_on(conn);
result
})
}
pub fn rebaseline_checksum_from_dir_locked(
database_url: &str,
migrations_dir: &Path,
version: &str,
wait_timeout: Option<std::time::Duration>,
) -> Result<(), MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
let Some(up_sql) = up_by_version.get(version) else {
return Err(MigrationError::Migration(format!(
"cannot re-baseline {version}: its up.sql was not found in {}",
migrations_dir.display()
)));
};
let new_checksum = migration_checksum(up_sql);
let timeout = wait_timeout.unwrap_or(DEFAULT_LOCK_WAIT_TIMEOUT);
with_migration_connection!(database_url, |conn| {
acquire_migration_lock_on(conn, timeout)?;
let result: Result<(), MigrationError> = (|| {
let is_applied = !diesel::sql_query(
"SELECT version FROM __diesel_schema_migrations WHERE version = $1",
)
.bind::<diesel::sql_types::Text, _>(version)
.load::<AppliedMigrationVersion>(conn)
.map_err(|e| MigrationError::Migration(e.to_string()))?
.is_empty();
if !is_applied {
return Err(MigrationError::Migration(format!(
"cannot re-baseline {version}: it is not a currently applied migration"
)));
}
rebaseline_checksum(conn, version, &new_checksum)
})();
release_migration_lock_on(conn);
result
})
}
pub fn checksum_status(
database_url: &str,
migrations_dir: &Path,
) -> Result<Vec<(String, ChecksumState)>, MigrationError> {
let up_by_version = read_up_sql_by_version(migrations_dir)?;
let framework = framework_migration_versions()?;
with_migration_connection!(database_url, |conn| {
let recorded = recorded_checksums(conn)?;
let applied = load_applied_versions_lenient(conn)?;
let user_applied = user_applied_versions(&applied, &up_by_version, &framework);
Ok(classify(&user_applied, &up_by_version, &recorded))
})
}
fn user_applied_versions<S>(
applied: &[String],
up_sql_by_version: &HashMap<String, String, S>,
framework: &std::collections::BTreeSet<String>,
) -> Vec<String>
where
S: std::hash::BuildHasher,
{
applied
.iter()
.filter(|v| up_sql_by_version.contains_key(*v) || !framework.contains(*v))
.cloned()
.collect()
}
fn resolve_applied_user_migrations<C: MigrationHarness<Pg>>(
conn: &mut C,
all_migrations: &[Box<dyn Migration<Pg>>],
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
let by_version: std::collections::BTreeMap<String, String> = all_migrations
.iter()
.map(|m| (m.name().version().to_string(), m.name().to_string()))
.collect();
let framework = framework_migration_versions()?;
let applied: Vec<String> = conn
.applied_migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?
.iter()
.map(ToString::to_string)
.collect();
Ok(classify_applied_user_migrations(
&applied,
&by_version,
&framework,
migrations_dir,
))
}
fn classify_applied_user_migrations(
applied: &[String],
by_version: &std::collections::BTreeMap<String, String>,
framework: &std::collections::BTreeSet<String>,
migrations_dir: &Path,
) -> Vec<AppliedUserMigration> {
let mut user: Vec<AppliedUserMigration> = applied
.iter()
.filter(|v| by_version.contains_key(*v) || !framework.contains(*v))
.map(|version| {
by_version.get(version).map_or_else(
|| AppliedUserMigration {
name: version.clone(),
dir: None,
version: version.clone(),
},
|name| AppliedUserMigration {
dir: Some(migrations_dir.join(name)),
name: name.clone(),
version: version.clone(),
},
)
})
.collect();
user.sort_by(|a, b| a.version.cmp(&b.version));
user
}
pub fn applied_user_migrations(
database_url: &str,
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
with_migration_connection!(database_url, |conn| {
let source = FileBasedMigrations::from_path(migrations_dir).map_err(|e| {
MigrationError::Migration(format!("failed to read migrations dir: {e}"))
})?;
let all_migrations: Vec<Box<dyn Migration<Pg>>> = source
.migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?;
resolve_applied_user_migrations(conn, &all_migrations, migrations_dir)
})
}
pub fn revert_user_migrations_locked<P, F>(
database_url: &str,
migrations_dir: &Path,
wait_timeout: Option<std::time::Duration>,
plan: P,
mut on_reverted: F,
) -> Result<usize, MigrationError>
where
P: FnOnce(&[AppliedUserMigration]) -> Result<Vec<String>, MigrationError>,
F: FnMut(&RevertedMigration),
{
let timeout = wait_timeout.unwrap_or(DEFAULT_LOCK_WAIT_TIMEOUT);
with_migration_connection!(database_url, |conn| {
let source = FileBasedMigrations::from_path(migrations_dir).map_err(|e| {
MigrationError::Migration(format!("failed to read migrations dir: {e}"))
})?;
let all_migrations: Vec<Box<dyn Migration<Pg>>> = source
.migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?;
acquire_migration_lock_on(conn, timeout)?;
let result: Result<usize, MigrationError> = (|| {
let applied_user =
resolve_applied_user_migrations(&mut *conn, &all_migrations, migrations_dir)?;
let versions = plan(&applied_user)?;
let mut count = 0;
for version in &versions {
let target = diesel::migration::MigrationVersion::from(version.as_str());
let migration = all_migrations
.iter()
.find(|m| m.name().version() == target)
.ok_or_else(|| {
MigrationError::Migration(format!(
"migration version {version} is applied but not present in {} — \
cannot revert (its down.sql is unavailable)",
migrations_dir.display()
))
})?;
let started = std::time::Instant::now();
conn.revert_migration(migration.as_ref())
.map_err(|e| MigrationError::Migration(e.to_string()))?;
let duration = started.elapsed();
if let Err(e) = delete_checksum(&mut *conn, version) {
tracing::warn!(
version = %version,
error = %e,
"Rolled back migration but could not clear its recorded content \
checksum; a later migrate may report drift for this version until \
it is re-applied or re-baselined"
);
}
on_reverted(&RevertedMigration {
version: version.clone(),
name: migration.name().to_string(),
duration,
});
count += 1;
}
Ok(count)
})();
release_migration_lock_on(conn);
result
})
}
#[cfg(feature = "sqlite")]
fn resolve_applied_user_migrations_sqlite<C>(
conn: &mut C,
all_migrations: &[Box<dyn Migration<diesel::sqlite::Sqlite>>],
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError>
where
C: MigrationHarness<diesel::sqlite::Sqlite>,
{
let by_version: std::collections::BTreeMap<String, String> = all_migrations
.iter()
.map(|m| (m.name().version().to_string(), m.name().to_string()))
.collect();
let framework = framework_migration_versions_for::<diesel::sqlite::Sqlite>()?;
let applied: Vec<String> = conn
.applied_migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?
.iter()
.map(ToString::to_string)
.collect();
Ok(classify_applied_user_migrations(
&applied,
&by_version,
&framework,
migrations_dir,
))
}
#[cfg(feature = "sqlite")]
pub fn applied_user_migrations_sqlite(
database_url: &str,
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
let mut conn = crate::db::establish_sqlite_migration_connection(database_url)
.map_err(|e| MigrationError::Connection(e.to_string()))?;
let source = FileBasedMigrations::from_path(migrations_dir)
.map_err(|e| MigrationError::Migration(format!("failed to read migrations dir: {e}")))?;
let all_migrations: Vec<Box<dyn Migration<diesel::sqlite::Sqlite>>> = source
.migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?;
resolve_applied_user_migrations_sqlite(&mut conn, &all_migrations, migrations_dir)
}
#[cfg(feature = "sqlite")]
pub fn revert_user_migrations_sqlite<P, F>(
database_url: &str,
migrations_dir: &Path,
plan: P,
mut on_reverted: F,
) -> Result<usize, MigrationError>
where
P: FnOnce(&[AppliedUserMigration]) -> Result<Vec<String>, MigrationError>,
F: FnMut(&RevertedMigration),
{
let mut conn = crate::db::establish_sqlite_migration_connection(database_url)
.map_err(|e| MigrationError::Connection(e.to_string()))?;
let source = FileBasedMigrations::from_path(migrations_dir)
.map_err(|e| MigrationError::Migration(format!("failed to read migrations dir: {e}")))?;
let all_migrations: Vec<Box<dyn Migration<diesel::sqlite::Sqlite>>> = source
.migrations()
.map_err(|e| MigrationError::Migration(e.to_string()))?;
let applied_user =
resolve_applied_user_migrations_sqlite(&mut conn, &all_migrations, migrations_dir)?;
let versions = plan(&applied_user)?;
let mut count = 0;
for version in &versions {
let target = diesel::migration::MigrationVersion::from(version.as_str());
let migration = all_migrations
.iter()
.find(|m| m.name().version() == target)
.ok_or_else(|| {
MigrationError::Migration(format!(
"migration version {version} is applied but not present in {} — \
cannot revert (its down.sql is unavailable)",
migrations_dir.display()
))
})?;
let started = std::time::Instant::now();
conn.revert_migration(migration.as_ref())
.map_err(|e| MigrationError::Migration(e.to_string()))?;
let duration = started.elapsed();
on_reverted(&RevertedMigration {
version: version.clone(),
name: migration.name().to_string(),
duration,
});
count += 1;
}
Ok(count)
}
#[derive(Debug)]
pub(crate) enum AttemptError {
Retryable(String),
Fatal(String),
}
pub(crate) fn is_retryable_connection_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("connection refused")
|| lower.contains("could not connect to server")
|| lower.contains("the database system is starting up")
|| lower.contains("connection reset")
|| lower.contains("no route to host")
|| lower.contains("host is unreachable")
|| lower.contains("network is unreachable")
|| lower.contains("timed out")
|| lower.contains("timeout expired")
|| lower.contains("connection closed")
|| lower.contains("name or service not known")
|| lower.contains("nodename nor servname provided")
|| lower.contains("failed to lookup")
|| lower.contains("temporary failure in name resolution")
}
pub(crate) fn backoff_delay(attempt: u32) -> std::time::Duration {
let ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1).min(10)));
std::time::Duration::from_millis(ms.min(5_000))
}
pub(crate) fn redact_db_url_credentials(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let mut rest = msg;
loop {
let pg = rest.find("postgres://");
let pgl = rest.find("postgresql://");
let start = match (pg, pgl) {
(None, None) => {
out.push_str(rest);
break;
}
(Some(a), None) => a,
(None, Some(b)) => b,
(Some(a), Some(b)) => a.min(b),
};
out.push_str(&rest[..start]);
rest = &rest[start..];
let token_end = rest
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(rest.len());
let token = &rest[..token_end];
if let Ok(mut parsed) = url::Url::parse(token) {
if parsed.password().is_some() {
let _ = parsed.set_password(Some("****"));
out.push_str(parsed.as_str());
} else {
out.push_str(token);
}
} else {
out.push_str("****");
}
rest = &rest[token_end..];
}
out
}
pub(crate) fn wait_for_database_inner(
max_wait: std::time::Duration,
mut try_connect: impl FnMut() -> Result<(), AttemptError>,
mut sleep: impl FnMut(std::time::Duration),
elapsed: impl Fn() -> std::time::Duration,
mut on_retry: impl FnMut(u32, std::time::Duration),
) -> Result<(), MigrationError> {
let mut attempt: u32 = 0;
loop {
attempt += 1;
let retryable_msg = match try_connect() {
Ok(()) => return Ok(()),
Err(AttemptError::Fatal(msg)) => {
return Err(MigrationError::Connection(redact_db_url_credentials(&msg)));
}
Err(AttemptError::Retryable(msg)) => msg,
};
let elapsed_now = elapsed();
if elapsed_now >= max_wait {
return Err(MigrationError::Connection(format!(
"database did not become reachable within {}s after {} attempt(s); \
timed out waiting for startup (last error: {})",
max_wait.as_secs(),
attempt,
redact_db_url_credentials(&retryable_msg),
)));
}
let delay = backoff_delay(attempt).min(max_wait.saturating_sub(elapsed_now));
on_retry(attempt, delay);
sleep(delay);
if elapsed() >= max_wait {
return Err(MigrationError::Connection(format!(
"database did not become reachable within {}s after {} attempt(s); \
timed out waiting for startup (last error: {})",
max_wait.as_secs(),
attempt,
redact_db_url_credentials(&retryable_msg),
)));
}
}
}
fn with_connect_timeout(url: &str, timeout_secs: u64) -> String {
if crate::pg_conn_str::is_url(url) {
return url::Url::parse(url).map_or_else(
|_| url.to_owned(),
|mut parsed| {
let pair = format!("connect_timeout={timeout_secs}");
let raw = parsed
.query()
.unwrap_or("")
.split('&')
.filter(|p| !p.is_empty() && !p.starts_with("connect_timeout="))
.chain(std::iter::once(pair.as_str()))
.collect::<Vec<_>>()
.join("&");
parsed.set_query(Some(&raw));
parsed.to_string()
},
);
}
match crate::pg_conn_str::keyword_value_pairs(url) {
Some(pairs) if !pairs.iter().any(|(key, _)| key == "connect_timeout") => {
format!("{url} connect_timeout={timeout_secs}")
}
_ => url.to_owned(),
}
}
pub fn wait_for_database(
database_url: &str,
max_wait: std::time::Duration,
mut on_retry: impl FnMut(u32, std::time::Duration),
) -> Result<(), MigrationError> {
let start = std::time::Instant::now();
wait_for_database_inner(
max_wait,
|| {
let remaining = max_wait.saturating_sub(start.elapsed());
let connect_timeout_secs = remaining.as_secs().max(1);
let timed_url = with_connect_timeout(database_url, connect_timeout_secs);
crate::db::establish_migration_connection(&timed_url)
.map(|_conn| ())
.map_err(|e| {
let msg = e.to_string();
if is_retryable_connection_error(&msg) {
AttemptError::Retryable(msg)
} else {
AttemptError::Fatal(msg)
}
})
},
std::thread::sleep,
move || start.elapsed(),
|attempt, delay| {
tracing::warn!(
attempt,
next_delay_ms = delay.as_millis(),
"Database not reachable; retrying after backoff",
);
on_retry(attempt, delay);
},
)
}
pub fn hold_migration_lock(
database_url: &str,
wait_timeout: std::time::Duration,
) -> Result<MigrationLockGuard, MigrationError> {
let mut conn = crate::db::establish_migration_connection(database_url)
.map_err(|e| MigrationError::Connection(e.to_string()))?;
match &mut conn {
crate::db::MigrationConnection::Native(conn) => {
acquire_migration_lock_on(conn, wait_timeout)?;
}
crate::db::MigrationConnection::Rustls { conn, .. } => {
acquire_migration_lock_on(conn, wait_timeout)?;
}
}
Ok(MigrationLockGuard { conn })
}
pub fn run_pending_locked(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::pg::Pg>,
wait_timeout: Option<std::time::Duration>,
) -> Result<MigrationResult, MigrationError> {
run_pending_locked_inner(database_url, migrations, wait_timeout, None)
}
fn run_pending_locked_inner(
database_url: &str,
migrations: impl diesel::migration::MigrationSource<diesel::pg::Pg>,
wait_timeout: Option<std::time::Duration>,
up_sql_by_version: Option<&HashMap<String, String>>,
) -> Result<MigrationResult, MigrationError> {
let timeout = wait_timeout.unwrap_or(DEFAULT_LOCK_WAIT_TIMEOUT);
with_migration_connection!(database_url, |conn| {
acquire_migration_lock_on(conn, timeout)?;
let outcome: Result<MigrationResult, MigrationError> = (|| {
if let Some(up) = up_sql_by_version {
let recorded = recorded_checksums(conn)?;
let applied = load_applied_versions_lenient(conn)?;
validate_checksums(&applied, up, &recorded)?;
}
let applied: Vec<String> = {
let mut harness = HarnessWithOutput::write_to_stdout(&mut *conn);
harness
.run_pending_migrations(migrations)
.map(|applied| applied.iter().map(|m| format!("{m}")).collect())
.map_err(|e| MigrationError::Migration(e.to_string()))?
};
if let Some(up) = up_sql_by_version {
let applied_versions = load_applied_versions_lenient(conn)?;
let recorded = recorded_checksums(conn)?;
validate_checksums(&applied_versions, up, &recorded)?;
}
Ok(MigrationResult { applied })
})();
release_migration_lock_on(conn);
outcome
})
}
pub fn run_pending_shard_framework_migrations(
database_url: &str,
) -> Result<MigrationResult, MigrationError> {
#[cfg(feature = "db")]
{
let mut applied: Vec<String> = Vec::new();
let vh_result = run_pending(
database_url,
EmbeddedMigrationsRef(&crate::version_history::VERSION_HISTORY_MIGRATIONS),
)?;
applied.extend(vh_result.applied);
let ch_result = run_pending(
database_url,
EmbeddedMigrationsRef(
&crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS,
),
)?;
applied.extend(ch_result.applied);
Ok(MigrationResult { applied })
}
#[cfg(not(feature = "db"))]
{
let _ = database_url;
Ok(MigrationResult {
applied: Vec::new(),
})
}
}
pub fn pending_shard_framework_migrations(
database_url: &str,
) -> Result<Vec<String>, MigrationError> {
#[cfg(feature = "db")]
{
let mut pending: Vec<String> = Vec::new();
pending.extend(pending_migrations(
database_url,
EmbeddedMigrationsRef(&crate::version_history::VERSION_HISTORY_MIGRATIONS),
)?);
pending.extend(pending_migrations(
database_url,
EmbeddedMigrationsRef(
&crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS,
),
)?);
Ok(pending)
}
#[cfg(not(feature = "db"))]
{
let _ = database_url;
Ok(Vec::new())
}
}
fn should_auto_apply(profile: Option<&str>, allow_auto_migrate_in_production: bool) -> bool {
let profile_name = profile.unwrap_or("none");
matches!(profile_name, "dev" | "development")
|| (matches!(profile_name, "prod" | "production") && allow_auto_migrate_in_production)
}
#[allow(clippy::cognitive_complexity)]
pub(crate) fn auto_migrate(
database_url: &str,
profile: Option<&str>,
allow_auto_migrate_in_production: bool,
migrations: &EmbeddedMigrations,
target: &str,
) {
let profile_name = profile.unwrap_or("none");
let is_dev = matches!(profile_name, "dev" | "development");
let is_prod = matches!(profile_name, "prod" | "production");
let should_auto_apply = should_auto_apply(profile, allow_auto_migrate_in_production);
if should_auto_apply {
if is_dev {
tracing::info!(target = %target, "Development profile: running pending database migrations...");
} else {
tracing::warn!(
profile = profile_name,
target = %target,
"Production auto-migration is enabled; running pending database migrations"
);
}
let migrations_dir = std::path::Path::new("migrations");
let up_by_version = if migrations_dir.is_dir() {
match read_up_sql_by_version(migrations_dir) {
Ok(map) => Some(map),
Err(e) => {
tracing::warn!(error = %e, target = %target, "Could not read migrations dir for checksum validation; continuing without the drift guard");
None
}
}
} else {
None
};
match run_pending_locked_inner(
database_url,
EmbeddedMigrationsRef(migrations),
None,
up_by_version.as_ref(),
) {
Ok(result) if result.applied.is_empty() => {
tracing::info!(target = %target, "No pending migrations");
}
Ok(result) => {
for name in &result.applied {
tracing::info!(migration = %name, target = %target, "Applied migration");
}
tracing::info!(
count = result.applied.len(),
target = %target,
"All pending migrations applied"
);
}
Err(MigrationError::Migration(msg))
if msg.contains("checksum mismatch")
|| msg.contains("up.sql is missing from the source tree") =>
{
tracing::error!(error = %msg, target = %target, "Applied migration has drifted from the source tree since it was applied");
#[cfg(feature = "managed-pg")]
crate::managed_pg::emergency_stop();
std::process::exit(1);
}
Err(e) => {
tracing::error!(error = %e, target = %target, "Failed to run migrations");
#[cfg(feature = "managed-pg")]
crate::managed_pg::emergency_stop();
std::process::exit(1);
}
}
} else {
match pending_migrations(database_url, EmbeddedMigrationsRef(migrations)) {
Ok(pending) if pending.is_empty() => {
tracing::info!(target = %target, "Database migrations are up to date");
}
Ok(pending) => {
if is_prod {
tracing::warn!(
"Production profile detected: automatic migrations are disabled by default. \
Run `autumn migrate check` to review safety before applying, then \
`autumn migrate` in your deployment job. \
Set database.auto_migrate_in_production=true only for single-process \
deployments after confirming all pending migrations are safe for a \
rolling deploy (expand/contract pattern)."
);
}
tracing::warn!(
count = pending.len(),
target = %target,
"Pending migrations detected. Run `autumn migrate` to apply them."
);
for name in &pending {
tracing::warn!(migration = %name, target = %target, "Pending migration");
}
}
Err(e) => {
tracing::warn!(error = %e, target = %target, "Could not check migration status");
}
}
}
}
#[cfg(feature = "sqlite")]
pub(crate) fn auto_migrate_sqlite(
database_url: &str,
profile: Option<&str>,
allow_auto_migrate_in_production: bool,
migrations: &EmbeddedMigrations,
target: &str,
) {
if let Some(err) = reject_in_memory_migrations(database_url, &EmbeddedMigrationsRef(migrations))
{
tracing::error!(
target = %target,
error = %err,
"Refusing to run SQLite migrations against an in-memory target",
);
std::process::exit(1);
}
if should_auto_apply(profile, allow_auto_migrate_in_production) {
tracing::info!(target = %target, "Running pending SQLite database migrations...");
match run_pending_sqlite(database_url, EmbeddedMigrationsRef(migrations)) {
Ok(result) if result.applied.is_empty() => {
tracing::info!(target = %target, "No pending migrations");
}
Ok(result) => {
for name in &result.applied {
tracing::info!(migration = %name, target = %target, "Applied migration");
}
tracing::info!(
count = result.applied.len(),
target = %target,
"All pending migrations applied"
);
}
Err(e) => {
tracing::error!(error = %e, target = %target, "Failed to run migrations");
std::process::exit(1);
}
}
} else {
match pending_migrations_sqlite(database_url, EmbeddedMigrationsRef(migrations)) {
Ok(pending) if pending.is_empty() => {
tracing::info!(target = %target, "Database migrations are up to date");
}
Ok(pending) => {
tracing::warn!(
count = pending.len(),
target = %target,
"Pending migrations detected. Run `autumn migrate` to apply them."
);
for name in &pending {
tracing::warn!(migration = %name, target = %target, "Pending migration");
}
}
Err(e) => {
tracing::warn!(error = %e, target = %target, "Could not check migration status");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_connect_timeout_appends_to_keyword_strings() {
assert_eq!(
with_connect_timeout("host=db user=app sslmode=require", 7),
"host=db user=app sslmode=require connect_timeout=7"
);
assert_eq!(
with_connect_timeout("host=db password='p w' sslmode = require", 7),
"host=db password='p w' sslmode = require connect_timeout=7"
);
}
#[test]
fn with_connect_timeout_respects_a_user_provided_keyword_timeout() {
assert_eq!(
with_connect_timeout("host=db connect_timeout=42 user=app", 7),
"host=db connect_timeout=42 user=app",
"an explicit user timeout must not be overridden"
);
}
#[test]
fn with_connect_timeout_url_behavior_is_unchanged() {
assert_eq!(
with_connect_timeout("postgres://u@h/db", 7),
"postgres://u@h/db?connect_timeout=7"
);
assert_eq!(
with_connect_timeout(
"postgres://u@h/db?options=-c%20search_path%3Dapp&connect_timeout=99",
7
),
"postgres://u@h/db?options=-c%20search_path%3Dapp&connect_timeout=7"
);
assert_eq!(with_connect_timeout("host=", 7), "host=");
}
#[test]
fn lock_timeout_error_display() {
let err = MigrationError::LockTimeout { timeout_secs: 60 };
let msg = err.to_string();
assert!(msg.contains("60"), "message must contain the timeout value");
assert!(
msg.to_lowercase().contains("lock") || msg.to_lowercase().contains("timeout"),
"message must mention lock or timeout: {msg}"
);
}
#[test]
fn migration_advisory_lock_key_is_positive_and_stable() {
const { assert!(MIGRATION_ADVISORY_LOCK_KEY > 0) };
assert_eq!(
MIGRATION_ADVISORY_LOCK_KEY,
0x6175_746E_5F6D_6967_u64.cast_signed()
);
}
#[test]
fn default_lock_wait_timeout_is_sixty_seconds() {
assert_eq!(DEFAULT_LOCK_WAIT_TIMEOUT.as_secs(), 60);
}
#[test]
fn run_pending_locked_fails_with_connection_error_on_bad_url() {
const MIGRATIONS: EmbeddedMigrations =
diesel_migrations::embed_migrations!("../examples/todo-app/migrations");
let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db";
let result = run_pending_locked(url, MIGRATIONS, None);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error, not LockTimeout"
);
}
#[test]
fn run_pending_locked_inner_with_checksum_map_still_errors_on_bad_url() {
const MIGRATIONS: EmbeddedMigrations =
diesel_migrations::embed_migrations!("../examples/todo-app/migrations");
let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db";
let mut up_by_version: HashMap<String, String> = HashMap::new();
up_by_version.insert("20260101000000".to_string(), "SELECT 1;".to_string());
let result = run_pending_locked_inner(url, MIGRATIONS, None, Some(&up_by_version));
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error even with a checksum map"
);
}
#[cfg(feature = "test-support")]
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn four_concurrent_runners_serialize_and_exactly_one_applies() {
use testcontainers::runners::AsyncRunner as _;
use testcontainers_modules::postgres::Postgres;
const TEST_MIGRATIONS: EmbeddedMigrations =
diesel_migrations::embed_migrations!("../examples/todo-app/migrations");
let container = Postgres::default()
.start()
.await
.expect("failed to start Postgres testcontainer (is Docker running?)");
let host = container.get_host().await.unwrap();
let port = container.get_host_port_ipv4(5432).await.unwrap();
let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
let handles: Vec<_> = (0..4)
.map(|_| {
let url = url.clone();
tokio::task::spawn_blocking(move || run_pending_locked(&url, TEST_MIGRATIONS, None))
})
.collect();
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.expect("task panicked"));
}
for result in &results {
assert!(
result.is_ok(),
"runner produced unexpected error: {result:?}"
);
}
let applied_count = results
.iter()
.filter(|r| r.as_ref().is_ok_and(|m| !m.applied.is_empty()))
.count();
assert_eq!(
applied_count, 1,
"exactly one runner should apply migrations; results={results:?}"
);
let final_check =
run_pending_locked(&url, TEST_MIGRATIONS, None).expect("post-run check failed");
assert!(
final_check.applied.is_empty(),
"schema must be fully applied after concurrent run"
);
}
#[cfg(feature = "test-support")]
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn checksum_loop_records_validates_and_detects_edits_against_live_db() {
use testcontainers::runners::AsyncRunner as _;
use testcontainers_modules::postgres::Postgres;
let container = Postgres::default()
.start()
.await
.expect("failed to start Postgres testcontainer (is Docker running?)");
let host = container.get_host().await.unwrap();
let port = container.get_host_port_ipv4(5432).await.unwrap();
let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
tokio::task::spawn_blocking(move || checksum_loop_body(&url))
.await
.expect("checksum loop task panicked");
}
#[cfg(feature = "test-support")]
fn checksum_table_exists(conn: &mut diesel::PgConnection) -> bool {
diesel::sql_query("SELECT to_regclass('autumn_migration_checksums') IS NOT NULL AS present")
.get_result::<TableExistsRow>(conn)
.expect("to_regclass probe must not error")
.present
}
#[cfg(feature = "test-support")]
#[allow(clippy::too_many_lines)] fn checksum_loop_body(url: &str) {
use diesel::Connection as _;
#[derive(diesel::QueryableByName)]
struct One {
#[diesel(sql_type = diesel::sql_types::Integer)]
one: i32,
}
let mut conn =
diesel::PgConnection::establish(url).expect("failed to connect to Postgres container");
assert!(
!checksum_table_exists(&mut conn),
"checksum table must be absent before the first checksum call"
);
let empty = recorded_checksums(&mut conn)
.expect("recorded_checksums must not error on a fresh DB (read-only, empty map)");
assert!(
empty.is_empty(),
"fresh DB must report no recorded checksums"
);
assert!(
!checksum_table_exists(&mut conn),
"recorded_checksums must NOT create the checksum table (read-only)"
);
let row = diesel::sql_query("SELECT 1 AS one")
.get_result::<One>(&mut conn)
.expect("connection must remain usable after the fresh-DB path");
assert_eq!(row.one, 1, "post-fresh-DB query must succeed");
run_pending(url, FRAMEWORK_MIGRATIONS).expect("framework migrations must apply");
assert!(
checksum_table_exists(&mut conn),
"framework migrations must create the checksum table"
);
assert!(
recorded_checksums(&mut conn)
.expect("table exists")
.is_empty(),
"checksum table must start empty"
);
let version = "20990101000000".to_string();
let up_sql = "CREATE TABLE checksum_demo (id BIGINT PRIMARY KEY);";
diesel::sql_query("INSERT INTO __diesel_schema_migrations (version) VALUES ($1)")
.bind::<diesel::sql_types::Text, _>(&version)
.execute(&mut conn)
.expect("record applied migration version");
let recorded_hash = migration_checksum(up_sql);
record_checksum(&mut conn, &version, &recorded_hash).expect("record_checksum");
let applied = vec![version.clone()];
let mut up_by_version: HashMap<String, String> = HashMap::new();
up_by_version.insert(version.clone(), up_sql.to_string());
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert_eq!(
recorded.get(&version).map(String::as_str),
Some(recorded_hash.as_str()),
"the recorded checksum must round-trip through the DB"
);
validate_checksums(&applied, &up_by_version, &recorded)
.expect("unedited content must validate Ok");
assert_eq!(
classify(&applied, &up_by_version, &recorded),
vec![(version.clone(), ChecksumState::Ok)],
);
let edited_sql = "CREATE TABLE checksum_demo (id BIGINT PRIMARY KEY, extra TEXT);";
let actual_hash = migration_checksum(edited_sql);
assert_ne!(
recorded_hash, actual_hash,
"edited content must hash differently"
);
let mut edited_by_version: HashMap<String, String> = HashMap::new();
edited_by_version.insert(version.clone(), edited_sql.to_string());
let err = validate_checksums(&applied, &edited_by_version, &recorded)
.expect_err("edited content must fail validation");
let msg = err.to_string();
assert!(msg.contains(&version), "error must name the version: {msg}");
assert!(
msg.contains(&recorded_hash),
"error must contain the recorded hash: {msg}"
);
assert!(
msg.contains(&actual_hash),
"error must contain the actual on-disk hash: {msg}"
);
assert_eq!(
classify(&applied, &edited_by_version, &recorded),
vec![(
version.clone(),
ChecksumState::Changed {
recorded: recorded_hash.clone(),
actual: actual_hash.clone(),
}
)],
);
let legacy = "20990102000000".to_string();
let legacy_sql = "CREATE TABLE checksum_legacy (id BIGINT PRIMARY KEY);";
diesel::sql_query("INSERT INTO __diesel_schema_migrations (version) VALUES ($1)")
.bind::<diesel::sql_types::Text, _>(&legacy)
.execute(&mut conn)
.expect("record legacy applied migration version");
up_by_version.insert(legacy.clone(), legacy_sql.to_string());
let applied_with_legacy = vec![version.clone(), legacy.clone()];
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert!(
!recorded.contains_key(&legacy),
"legacy version must have no recorded checksum yet"
);
let states = classify(&applied_with_legacy, &up_by_version, &recorded);
assert_eq!(
states.iter().find(|(v, _)| v == &legacy).map(|(_, s)| s),
Some(&ChecksumState::Unrecorded),
"an applied migration with no recorded checksum must classify as Unrecorded"
);
validate_checksums(&applied_with_legacy, &up_by_version, &recorded)
.expect("an Unrecorded legacy migration must NOT fail validation");
let newly = record_checksums(&mut conn, &applied_with_legacy, &up_by_version)
.expect("record_checksums baseline");
assert_eq!(
newly, 1,
"only the legacy version should be newly recorded (the other already has one)"
);
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert_eq!(
classify(&applied_with_legacy, &up_by_version, &recorded)
.iter()
.find(|(v, _)| v == &legacy)
.map(|(_, s)| s),
Some(&ChecksumState::Ok),
"after baseline the legacy migration must validate Ok"
);
rebaseline_checksum(&mut conn, &version, &actual_hash).expect("rebaseline_checksum");
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert_eq!(
recorded.get(&version).map(String::as_str),
Some(actual_hash.as_str()),
"rebaseline must overwrite the stored checksum"
);
validate_checksums(&applied, &edited_by_version, &recorded)
.expect("edited content must validate Ok after re-baseline");
assert_ne!(
migration_checksum(up_sql),
actual_hash,
"sanity: original up.sql must not match the re-baselined checksum"
);
let drift = run_pending_locked_inner(url, FRAMEWORK_MIGRATIONS, None, Some(&up_by_version))
.expect_err("under-lock validation must reject an applied migration that drifted");
assert!(
matches!(&drift, MigrationError::Migration(m) if m.contains("checksum mismatch") && m.contains(&version)),
"run_pending_locked_inner must validate checksums inside the locked section: {drift:?}"
);
diesel::sql_query("DELETE FROM __diesel_schema_migrations WHERE version = $1")
.bind::<diesel::sql_types::Text, _>(&version)
.execute(&mut conn)
.expect("simulate down: remove applied version");
let deleted = delete_checksums(&mut conn, std::slice::from_ref(&version))
.expect("delete_checksums must succeed");
assert_eq!(
deleted, 1,
"the reverted version's checksum row must be deleted"
);
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert!(
!recorded.contains_key(&version),
"after rollback the reverted version must have NO recorded checksum"
);
diesel::sql_query("INSERT INTO __diesel_schema_migrations (version) VALUES ($1)")
.bind::<diesel::sql_types::Text, _>(&version)
.execute(&mut conn)
.expect("re-apply: re-record applied version");
let newly = record_checksums(&mut conn, &applied, &edited_by_version)
.expect("record_checksums after re-apply");
assert_eq!(
newly, 1,
"re-apply after rollback must record a fresh checksum for the reverted version"
);
let recorded = recorded_checksums(&mut conn).expect("read recorded checksums");
assert_eq!(
recorded.get(&version).map(String::as_str),
Some(actual_hash.as_str()),
"re-apply must record the EDITED content's hash, not the stale original"
);
validate_checksums(&applied, &edited_by_version, &recorded)
.expect("edited content validates Ok after rollback + fresh re-apply");
let startup_version = "20990103000000".to_string();
let startup_sql = "CREATE TABLE checksum_startup (id BIGINT PRIMARY KEY);";
diesel::sql_query("INSERT INTO __diesel_schema_migrations (version) VALUES ($1)")
.bind::<diesel::sql_types::Text, _>(&startup_version)
.execute(&mut conn)
.expect("simulate startup-applied version");
let mut startup_by_version: HashMap<String, String> = HashMap::new();
startup_by_version.insert(startup_version.clone(), startup_sql.to_string());
assert!(
!recorded_checksums(&mut conn)
.expect("read recorded checksums")
.contains_key(&startup_version),
"precondition: the startup version must have no recorded checksum yet"
);
run_pending_locked_inner(url, FRAMEWORK_MIGRATIONS, None, Some(&startup_by_version))
.expect("startup path validates an Unrecorded version as Ok");
assert!(
!recorded_checksums(&mut conn)
.expect("read recorded checksums")
.contains_key(&startup_version),
"the startup auto-migrate path must NOT record checksums from disk bytes; \
recording is deferred to the CLI/baseline paths"
);
}
#[test]
fn applied_user_migrations_fails_with_connection_error_on_bad_url() {
let dir = std::path::Path::new("../examples/todo-app/migrations");
let result =
applied_user_migrations("postgres://invalid:invalid@0.0.0.0:1/invalid_db", dir);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error"
);
}
#[test]
fn revert_user_migrations_locked_fails_with_connection_error_on_bad_url() {
let dir = std::path::Path::new("../examples/todo-app/migrations");
let mut planned = false;
let result = revert_user_migrations_locked(
"postgres://invalid:invalid@0.0.0.0:1/invalid_db",
dir,
None,
|_applied| {
planned = true;
Ok(Vec::new())
},
|_| {},
);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error"
);
assert!(
!planned,
"plan closure must not run when the connection fails"
);
}
#[test]
fn record_checksums_from_dir_locked_fails_with_connection_error_on_bad_url() {
let dir = std::path::Path::new("../examples/todo-app/migrations");
let result = record_checksums_from_dir_locked(
"postgres://invalid:invalid@0.0.0.0:1/invalid_db",
dir,
None,
);
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error"
);
}
#[test]
fn rebaseline_checksum_from_dir_locked_fails_with_connection_error_on_bad_url() {
let dir = std::path::Path::new("../examples/todo-app/migrations");
let result = rebaseline_checksum_from_dir_locked(
"postgres://invalid:invalid@0.0.0.0:1/invalid_db",
dir,
"00000000000000",
None,
);
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error"
);
}
#[test]
fn rebaseline_checksum_from_dir_locked_errors_before_connecting_on_unknown_version() {
let dir = std::path::Path::new("../examples/todo-app/migrations");
let result = rebaseline_checksum_from_dir_locked(
"postgres://invalid:invalid@0.0.0.0:1/invalid_db",
dir,
"99999999999999",
None,
);
assert!(
matches!(result.unwrap_err(), MigrationError::Migration(m) if m.contains("99999999999999")),
"an unknown version must fail fast with a Migration error naming it"
);
}
#[test]
fn applied_user_migration_resolves_dir_field() {
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000_create_posts".to_string(),
dir: Some(std::path::PathBuf::from(
"migrations/20260101000000_create_posts",
)),
};
let s = format!("{m:?}");
assert!(s.contains("create_posts"));
assert!(m.dir.is_some());
}
fn version_map(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
pairs
.iter()
.map(|(v, n)| ((*v).to_string(), (*n).to_string()))
.collect()
}
fn version_set(versions: &[&str]) -> std::collections::BTreeSet<String> {
versions.iter().map(|v| (*v).to_string()).collect()
}
#[test]
fn classify_excludes_framework_versions_absent_locally() {
let applied = vec!["00000000000000".to_string(), "20260101000000".to_string()];
let by_version = version_map(&[("20260101000000", "20260101000000_create_posts")]);
let framework = version_set(&["00000000000000"]);
let user = classify_applied_user_migrations(
&applied,
&by_version,
&framework,
Path::new("migrations"),
);
assert_eq!(user.len(), 1);
assert_eq!(user[0].version, "20260101000000");
assert_eq!(
user[0].dir,
Some(Path::new("migrations").join("20260101000000_create_posts"))
);
}
#[test]
fn classify_keeps_user_migration_colliding_with_framework_version() {
let applied = vec!["00000000000000".to_string()];
let by_version = version_map(&[("00000000000000", "00000000000000_create_todos")]);
let framework = version_set(&["00000000000000"]);
let user = classify_applied_user_migrations(
&applied,
&by_version,
&framework,
Path::new("migrations"),
);
assert_eq!(
user.len(),
1,
"user migration sharing a framework version must not be dropped"
);
assert_eq!(user[0].name, "00000000000000_create_todos");
assert!(user[0].dir.is_some());
}
#[test]
fn classify_surfaces_applied_migration_missing_locally() {
let applied = vec!["20260101000000".to_string()];
let by_version = version_map(&[]);
let framework = version_set(&["00000000000000"]);
let user = classify_applied_user_migrations(
&applied,
&by_version,
&framework,
Path::new("migrations"),
);
assert_eq!(user.len(), 1);
assert_eq!(user[0].version, "20260101000000");
assert!(
user[0].dir.is_none(),
"missing-locally migration must have dir = None"
);
}
#[test]
fn classify_sorts_ascending_and_resolves_hyphenated_dirs() {
let applied = vec!["20260102000000".to_string(), "20260101000000".to_string()];
let by_version = version_map(&[
("20260101000000", "2026-01-01-000000_create_posts"),
("20260102000000", "20260102000000_add_body"),
]);
let framework = version_set(&[]);
let user = classify_applied_user_migrations(
&applied,
&by_version,
&framework,
Path::new("migrations"),
);
assert_eq!(user.len(), 2);
assert_eq!(user[0].version, "20260101000000");
assert_eq!(user[1].version, "20260102000000");
assert_eq!(
user[0].dir,
Some(Path::new("migrations").join("2026-01-01-000000_create_posts"))
);
}
#[test]
fn reverted_migration_debug_includes_name() {
let r = RevertedMigration {
version: "20260101000000".to_string(),
name: "20260101000000_create_posts".to_string(),
duration: std::time::Duration::from_millis(42),
};
let s = format!("{r:?}");
assert!(s.contains("create_posts"));
assert!(s.contains("20260101000000"));
}
#[test]
fn migration_result_debug() {
let result = MigrationResult {
applied: vec!["00000000000001".to_string()],
};
let debug = format!("{result:?}");
assert!(debug.contains("00000000000001"));
}
#[test]
fn migration_error_display_connection() {
let err = MigrationError::Connection("refused".to_string());
let msg = err.to_string();
assert!(msg.contains("connect"));
assert!(msg.contains("refused"));
}
#[test]
fn migration_error_display_migration() {
let err = MigrationError::Migration("syntax error".to_string());
let msg = err.to_string();
assert!(msg.contains("migration failed"));
assert!(msg.contains("syntax error"));
}
#[test]
fn replica_migration_comparison_detects_stale_replica() {
let primary = vec!["00000000000001".to_owned(), "00000000000002".to_owned()];
let replica = vec!["00000000000001".to_owned()];
let readiness = compare_replica_migration_versions(&primary, &replica);
assert!(!readiness.is_ready());
assert!(
readiness
.detail()
.expect("stale detail")
.contains("00000000000002")
);
}
#[test]
fn profile_aliases_are_recognized() {
assert!(should_auto_apply(Some("dev"), false));
assert!(should_auto_apply(Some("development"), false));
assert!(!should_auto_apply(Some("prod"), false));
assert!(!should_auto_apply(Some("production"), false));
assert!(should_auto_apply(Some("prod"), true));
assert!(should_auto_apply(Some("production"), true));
assert!(!should_auto_apply(Some("staging"), true));
}
#[test]
fn run_pending_connection_error() {
const MIGRATIONS: EmbeddedMigrations =
diesel_migrations::embed_migrations!("../examples/todo-app/migrations");
let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db";
let result = run_pending(url, MIGRATIONS);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), MigrationError::Connection(_)));
}
#[test]
fn replica_migration_readiness_ready_is_ready_and_has_no_detail() {
assert!(ReplicaMigrationReadiness::Ready.is_ready());
assert_eq!(ReplicaMigrationReadiness::Ready.detail(), None);
}
#[test]
fn replica_migration_readiness_unknown_is_not_ready_and_has_detail() {
let r = ReplicaMigrationReadiness::Unknown("db error xyz".to_string());
assert!(!r.is_ready());
let detail = r.detail().expect("Unknown must have detail");
assert!(
detail.contains("db error xyz"),
"detail must contain the error: {detail}"
);
}
#[test]
fn compare_migration_versions_equal_returns_ready() {
let versions = vec!["00000000000001".to_owned(), "00000000000002".to_owned()];
let readiness = compare_replica_migration_versions(&versions, &versions.clone());
assert!(readiness.is_ready());
assert_eq!(readiness.detail(), None);
}
#[test]
fn hold_migration_lock_fails_with_connection_error_on_bad_url() {
let result = hold_migration_lock(
"postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db",
DEFAULT_LOCK_WAIT_TIMEOUT,
);
assert!(
matches!(result.unwrap_err(), MigrationError::Connection(_)),
"unreachable host must produce Connection error"
);
}
#[test]
fn pending_migrations_fails_with_connection_error_on_bad_url() {
const MIGRATIONS: EmbeddedMigrations =
diesel_migrations::embed_migrations!("../examples/todo-app/migrations");
let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db";
let result = pending_migrations(url, MIGRATIONS);
assert!(matches!(result.unwrap_err(), MigrationError::Connection(_)));
}
#[test]
fn stale_detail_uses_none_placeholder_when_primary_is_empty() {
let empty: Vec<String> = vec![];
let replica = vec!["00000000000001".to_owned()];
let r = compare_replica_migration_versions(&empty, &replica);
assert!(!r.is_ready());
let detail = r.detail().expect("stale must have detail");
assert!(
detail.contains("<none>"),
"empty primary must use <none>: {detail}"
);
assert!(detail.contains("00000000000001"));
}
#[test]
fn should_auto_apply_returns_false_for_none_profile() {
assert!(!should_auto_apply(None, false));
assert!(!should_auto_apply(None, true));
}
#[test]
fn is_retryable_connection_refused() {
assert!(is_retryable_connection_error("connection refused"));
assert!(is_retryable_connection_error(
"FATAL: connection refused (os error 111)"
));
}
#[test]
fn is_retryable_server_starting_up() {
assert!(is_retryable_connection_error(
"the database system is starting up"
));
}
#[test]
fn is_retryable_timed_out() {
assert!(is_retryable_connection_error("connection timed out"));
assert!(is_retryable_connection_error(
"timed out waiting for server"
));
assert!(is_retryable_connection_error("timeout expired"));
assert!(is_retryable_connection_error(
"ERROR: SSL connection: timeout expired"
));
}
#[test]
fn is_retryable_could_not_connect() {
assert!(is_retryable_connection_error(
"could not connect to server: Connection refused"
));
}
#[test]
fn is_not_retryable_auth_failure() {
assert!(!is_retryable_connection_error(
"password authentication failed for user \"app\""
));
}
#[test]
fn is_not_retryable_database_does_not_exist() {
assert!(!is_retryable_connection_error(
"database \"mydb\" does not exist"
));
}
#[test]
fn is_not_retryable_invalid_url() {
assert!(!is_retryable_connection_error(
"invalid connection string syntax"
));
}
#[test]
fn is_not_retryable_role_does_not_exist() {
assert!(!is_retryable_connection_error(
"role \"app\" does not exist"
));
}
#[test]
fn is_retryable_dns_linux() {
assert!(is_retryable_connection_error(
"could not translate host name \"db\" to address: \
Name or service not known"
));
}
#[test]
fn is_retryable_dns_macos() {
assert!(is_retryable_connection_error(
"could not translate host name \"db\" to address: \
nodename nor servname provided, or not known"
));
}
#[test]
fn is_retryable_dns_temporary_failure() {
assert!(is_retryable_connection_error(
"Temporary failure in name resolution"
));
}
#[test]
fn backoff_delay_grows_and_caps() {
let d = |n| backoff_delay(n).as_millis();
assert_eq!(d(1), 500);
assert_eq!(d(2), 1000);
assert_eq!(d(3), 2000);
assert_eq!(d(4), 4000);
assert_eq!(d(5), 5000); assert_eq!(d(10), 5000); }
#[test]
fn redact_removes_password_from_url() {
let msg = "failed: postgres://user:secret@host:5432/db";
let out = redact_db_url_credentials(msg);
assert!(
!out.contains("secret"),
"password must not appear in output: {out}"
);
}
#[test]
fn redact_no_creds_url_unchanged_format() {
let msg = "connection refused at postgres://host:5432/db";
let out = redact_db_url_credentials(msg);
assert!(
!out.contains("****"),
"no-cred url should not be mangled: {out}"
);
assert!(
out.contains("host:5432"),
"host should still be present: {out}"
);
}
#[test]
fn redact_removes_password_from_postgresql_scheme() {
let msg = "failed: postgresql://user:s3cret@host:5432/db";
let out = redact_db_url_credentials(msg);
assert!(
!out.contains("s3cret"),
"password must not appear when scheme is postgresql://: {out}"
);
assert!(out.contains("****"), "masked marker missing: {out}");
}
#[test]
fn redact_masks_whole_token_on_parse_failure() {
let msg = "error: postgres://user:p@ss@host/db";
let out = redact_db_url_credentials(msg);
assert!(
!out.contains("p@ss"),
"unparseable password must not leak: {out}"
);
}
#[test]
fn wait_for_database_inner_success_first_attempt() {
use std::cell::Cell;
let attempts = Cell::new(0u32);
let sleeps = Cell::new(0u32);
let result = wait_for_database_inner(
std::time::Duration::from_secs(30),
|| {
attempts.set(attempts.get() + 1);
Ok(())
},
|_| sleeps.set(sleeps.get() + 1),
|| std::time::Duration::ZERO,
|_, _| {},
);
assert!(result.is_ok());
assert_eq!(attempts.get(), 1);
assert_eq!(sleeps.get(), 0);
}
#[test]
fn wait_for_database_inner_fatal_error_no_retry() {
use std::cell::Cell;
let attempts = Cell::new(0u32);
let retried = Cell::new(false);
let result = wait_for_database_inner(
std::time::Duration::from_secs(30),
|| {
attempts.set(attempts.get() + 1);
Err(AttemptError::Fatal(
"password authentication failed".to_string(),
))
},
|_| {},
|| std::time::Duration::ZERO,
|_, _| retried.set(true),
);
assert!(result.is_err());
assert_eq!(attempts.get(), 1, "fatal error must not retry");
assert!(!retried.get(), "on_retry must not fire for fatal errors");
}
#[test]
fn wait_for_database_inner_success_on_third_attempt() {
use std::cell::Cell;
let attempts = Cell::new(0u32);
let mut sleep_delays = Vec::new();
let result = wait_for_database_inner(
std::time::Duration::from_secs(30),
|| {
let n = attempts.get() + 1;
attempts.set(n);
if n < 3 {
Err(AttemptError::Retryable("connection refused".to_string()))
} else {
Ok(())
}
},
|d| sleep_delays.push(d),
|| std::time::Duration::ZERO, |_, _| {},
);
assert!(result.is_ok());
assert_eq!(attempts.get(), 3);
assert_eq!(sleep_delays.len(), 2);
assert_eq!(sleep_delays[0], std::time::Duration::from_millis(500));
assert_eq!(sleep_delays[1], std::time::Duration::from_secs(1));
}
#[test]
fn wait_for_database_inner_timeout_returns_error() {
use std::cell::Cell;
let attempts = Cell::new(0u32);
let result = wait_for_database_inner(
std::time::Duration::from_secs(5),
|| {
attempts.set(attempts.get() + 1);
Err(AttemptError::Retryable("connection refused".to_string()))
},
|_| {},
|| std::time::Duration::from_secs(10), |_, _| {},
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.to_lowercase().contains("wait")
|| msg.to_lowercase().contains("timeout")
|| msg.to_lowercase().contains("timed out"),
"error must describe the timeout: {msg}"
);
}
#[test]
fn migration_checksum_lf_and_crlf_hash_equally() {
let lf = "CREATE TABLE t (id INT);\nCREATE INDEX i ON t (id);\n";
let crlf = "CREATE TABLE t (id INT);\r\nCREATE INDEX i ON t (id);\r\n";
let cr = "CREATE TABLE t (id INT);\rCREATE INDEX i ON t (id);\r";
assert_eq!(migration_checksum(lf), migration_checksum(crlf));
assert_eq!(migration_checksum(lf), migration_checksum(cr));
}
#[test]
fn migration_checksum_ignores_trailing_whitespace() {
let a = "SELECT 1;\n";
let b = "SELECT 1;";
let c = "SELECT 1;\n\n\n \n";
assert_eq!(migration_checksum(a), migration_checksum(b));
assert_eq!(migration_checksum(a), migration_checksum(c));
}
#[test]
fn migration_checksum_differs_on_semantic_edit() {
let orig = "CREATE TABLE t (id INT);\n";
let edited = "CREATE TABLE t (id BIGINT);\n";
assert_ne!(migration_checksum(orig), migration_checksum(edited));
}
#[test]
fn migration_checksum_is_hex_sha256() {
let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
assert_eq!(migration_checksum("abc"), expected);
}
#[test]
fn migration_checksum_bytes_matches_string_form() {
let sql = "CREATE TABLE t (id INT);\r\n";
assert_eq!(
migration_checksum(sql),
migration_checksum_bytes(sql.as_bytes())
);
}
fn checksum_map(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
pairs
.iter()
.map(|(v, s)| ((*v).to_string(), (*s).to_string()))
.collect()
}
#[test]
fn classify_marks_matching_hash_ok() {
let up = "SELECT 1;\n";
let applied = vec!["20260101000000".to_string()];
let up_map = checksum_map(&[("20260101000000", up)]);
let recorded = checksum_map(&[("20260101000000", &migration_checksum(up))]);
let result = classify(&applied, &up_map, &recorded);
assert_eq!(result.len(), 1);
assert!(matches!(result[0].1, ChecksumState::Ok));
}
#[test]
fn classify_flags_edited_migration_as_changed() {
let original = "SELECT 1;\n";
let edited = "SELECT 2;\n";
let applied = vec!["20260101000000".to_string()];
let up_map = checksum_map(&[("20260101000000", edited)]);
let recorded = checksum_map(&[("20260101000000", &migration_checksum(original))]);
let result = classify(&applied, &up_map, &recorded);
assert_eq!(result.len(), 1);
match &result[0].1 {
ChecksumState::Changed { recorded, actual } => {
assert_eq!(recorded, &migration_checksum(original));
assert_eq!(actual, &migration_checksum(edited));
}
other => panic!("expected Changed, got {other:?}"),
}
}
#[test]
fn classify_marks_unrecorded_when_missing_from_recorded_map() {
let up = "SELECT 1;\n";
let applied = vec!["20260101000000".to_string()];
let up_map = checksum_map(&[("20260101000000", up)]);
let recorded = std::collections::HashMap::new();
let result = classify(&applied, &up_map, &recorded);
assert_eq!(result.len(), 1);
assert!(matches!(result[0].1, ChecksumState::Unrecorded));
}
#[test]
fn classify_marks_unrecorded_when_up_sql_unresolvable() {
let applied = vec!["20260101000000".to_string()];
let up_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let recorded = std::collections::HashMap::new();
let result = classify(&applied, &up_map, &recorded);
assert_eq!(result.len(), 1);
assert!(matches!(result[0].1, ChecksumState::Unrecorded));
}
#[test]
fn validate_checksums_returns_err_on_first_changed() {
let orig = "SELECT 1;\n";
let edited = "SELECT 2;\n";
let applied = vec!["20260101000000".to_string()];
let up_map = checksum_map(&[("20260101000000", edited)]);
let recorded = checksum_map(&[("20260101000000", &migration_checksum(orig))]);
let err = validate_checksums(&applied, &up_map, &recorded).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("checksum mismatch"),
"message must name the failure mode: {msg}"
);
assert!(
msg.contains("20260101000000"),
"message must include the version: {msg}"
);
assert!(
msg.contains(&migration_checksum(orig)),
"message must include the recorded hex: {msg}"
);
assert!(
msg.contains(&migration_checksum(edited)),
"message must include the actual hex: {msg}"
);
}
#[test]
fn validate_checksums_tolerates_unrecorded() {
let up = "SELECT 1;\n";
let applied = vec!["20260101000000".to_string()];
let up_map = checksum_map(&[("20260101000000", up)]);
let recorded = std::collections::HashMap::new();
assert!(validate_checksums(&applied, &up_map, &recorded).is_ok());
}
#[test]
fn classify_marks_missing_when_recorded_but_up_sql_gone() {
let original = "SELECT 1;\n";
let applied = vec!["20260101000000".to_string()];
let up_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let recorded = checksum_map(&[("20260101000000", &migration_checksum(original))]);
let result = classify(&applied, &up_map, &recorded);
assert_eq!(result.len(), 1);
match &result[0].1 {
ChecksumState::Missing { recorded } => {
assert_eq!(recorded, &migration_checksum(original));
}
other => panic!("expected Missing, got {other:?}"),
}
}
#[test]
fn validate_checksums_fails_on_missing() {
let original = "SELECT 1;\n";
let version = "20260101000000";
let applied = vec![version.to_string()];
let up_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let recorded = checksum_map(&[(version, &migration_checksum(original))]);
let err = validate_checksums(&applied, &up_map, &recorded)
.expect_err("a recorded migration whose up.sql is gone must fail validation");
let msg = err.to_string();
assert!(
msg.contains(version),
"message must name the version: {msg}"
);
assert!(
msg.contains(&migration_checksum(original)),
"message must include the recorded hex: {msg}"
);
assert!(
msg.contains("up.sql is missing from the source tree"),
"message must name the failure mode: {msg}"
);
assert!(
msg.contains("must never be deleted or renamed"),
"message must state the remedy: {msg}"
);
}
#[test]
fn validate_checksums_tolerates_absent_up_sql_without_recorded_checksum() {
let ok_up = "SELECT 1;\n";
let framework_version = "00000000000000".to_string();
let user_version = "20260101000000".to_string();
let applied = vec![framework_version.clone(), user_version];
let up_map = checksum_map(&[("20260101000000", ok_up)]);
let recorded = checksum_map(&[("20260101000000", &migration_checksum(ok_up))]);
let states = classify(&applied, &up_map, &recorded);
assert_eq!(
states
.iter()
.find(|(v, _)| v == &framework_version)
.map(|(_, s)| s),
Some(&ChecksumState::Unrecorded),
"an applied version with no recorded checksum and no file must be Unrecorded"
);
validate_checksums(&applied, &up_map, &recorded)
.expect("an unrecorded absent migration must not hard-fail validation");
}
#[test]
fn checksum_status_excludes_framework_but_keeps_user_unrecorded() {
let framework_version = "00000000000000".to_string();
let user_recorded_version = "20260101000000".to_string();
let user_unrecorded_version = "20260102000000".to_string();
let applied = vec![
framework_version.clone(),
user_recorded_version.clone(),
user_unrecorded_version.clone(),
];
let ok_up = "SELECT 1;\n";
let pending_up = "SELECT 2;\n";
let up_map = checksum_map(&[("20260101000000", ok_up), ("20260102000000", pending_up)]);
let recorded = checksum_map(&[("20260101000000", &migration_checksum(ok_up))]);
let framework = version_set(&["00000000000000"]);
let user_applied = user_applied_versions(&applied, &up_map, &framework);
assert!(
!user_applied.contains(&framework_version),
"applied framework version must be excluded from checksum status"
);
let states = classify(&user_applied, &up_map, &recorded);
assert!(
states.iter().all(|(v, _)| v != &framework_version),
"framework version must not appear in checksum status output"
);
assert_eq!(
states
.iter()
.find(|(v, _)| v == &user_recorded_version)
.map(|(_, s)| s),
Some(&ChecksumState::Ok),
);
assert_eq!(
states
.iter()
.find(|(v, _)| v == &user_unrecorded_version)
.map(|(_, s)| s),
Some(&ChecksumState::Unrecorded),
"a user-owned unrecorded migration must still classify Unrecorded"
);
}
#[test]
fn user_applied_versions_keeps_disk_missing_user_migration() {
let framework_version = "00000000000000".to_string();
let missing_user_version = "20260101000000".to_string();
let applied = vec![framework_version.clone(), missing_user_version.clone()];
let up_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let framework = version_set(&["00000000000000"]);
let user_applied = user_applied_versions(&applied, &up_map, &framework);
assert_eq!(user_applied, vec![missing_user_version]);
assert!(
!user_applied.contains(&framework_version),
"framework version absent from disk must be dropped"
);
}
}