pub mod safety;
pub mod versions;
use std::path::Path;
use std::process::Command;
use autumn_web::migrate::{
AppliedUserMigration, EmbeddedMigrations, FRAMEWORK_MIGRATIONS, MigrationError, MigrationResult,
};
const DEFAULT_MIGRATIONS_DIR: &str = "migrations";
#[cfg(not(feature = "sqlite"))]
const SQLITE_FEATURE_MSG: &str = "`autumn migrate` detected a SQLite database URL, but this CLI build targets Postgres only. \
Rebuild with `--no-default-features --features sqlite` to run migrations against a \
`sqlite://` database (the sqlite backend-flip must never be co-built with the default \
Postgres backend).";
fn is_sqlite_target(database_url: &str) -> bool {
target_backend(database_url) == autumn_web::config::DatabaseBackend::Sqlite
}
fn target_backend(database_url: &str) -> autumn_web::config::DatabaseBackend {
autumn_web::config::DatabaseBackend::detect(database_url)
.unwrap_or(autumn_web::config::DatabaseBackend::Postgres)
}
fn framework_migrations_backend_mismatch(database_url: &str) -> Result<(), String> {
if cfg!(feature = "sqlite") && !is_sqlite_target(database_url) {
return Err(
"this CLI build was compiled with `--features sqlite`, so its embedded framework \
migrations are the SQLite fork for the whole binary (chosen once, at compile time) \
\u{2014} not selected per target. This target's URL is not `sqlite://`, so applying \
them would run SQLite-shaped DDL, including no-op shims, against a different \
database instead of the real schema. Rebuild the default (PostgreSQL) CLI for this \
target; the `sqlite` feature must never be pointed at a non-SQLite database."
.to_owned(),
);
}
Ok(())
}
fn all_targets_sqlite(targets: &[(String, String)]) -> bool {
!targets.is_empty() && targets.iter().all(|(_, url)| is_sqlite_target(url))
}
#[cfg(feature = "sqlite")]
fn apply_pending_sqlite_cli(
database_url: &str,
migrations_dir: &Path,
) -> Result<MigrationResult, MigrationError> {
let migrations =
diesel_migrations::FileBasedMigrations::from_path(migrations_dir).map_err(|e| {
MigrationError::Migration(format!(
"failed to read migrations directory {}: {e}",
migrations_dir.display()
))
})?;
autumn_web::migrate::run_pending_sqlite_with_framework_migrations(database_url, &migrations)
}
#[cfg(not(feature = "sqlite"))]
fn apply_pending_sqlite_cli(
_database_url: &str,
_migrations_dir: &Path,
) -> Result<MigrationResult, MigrationError> {
Err(MigrationError::Migration(SQLITE_FEATURE_MSG.to_owned()))
}
fn applied_user_migrations_for_target(
database_url: &str,
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
if is_sqlite_target(database_url) {
applied_user_migrations_sqlite_cli(database_url, migrations_dir)
} else {
autumn_web::migrate::applied_user_migrations(database_url, migrations_dir)
}
}
#[cfg(feature = "sqlite")]
fn applied_user_migrations_sqlite_cli(
database_url: &str,
migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
autumn_web::migrate::applied_user_migrations_sqlite(database_url, migrations_dir)
}
#[cfg(not(feature = "sqlite"))]
fn applied_user_migrations_sqlite_cli(
_database_url: &str,
_migrations_dir: &Path,
) -> Result<Vec<AppliedUserMigration>, MigrationError> {
Err(MigrationError::Migration(SQLITE_FEATURE_MSG.to_owned()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownArgs {
pub steps: Option<usize>,
pub to: Option<String>,
pub yes_i_mean_prod: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaselineArgs {
pub force_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrateAction {
Run,
Status,
Check,
Down(DownArgs),
Baseline(BaselineArgs),
}
pub struct MigrationSafetyReport {
pub name: String,
pub up: Vec<safety::SafetyFinding>,
pub down: Vec<safety::SafetyFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrateTarget {
All,
ControlOnly,
Shard(String),
}
pub fn run(
action: &MigrateAction,
with_maintenance: bool,
target: &MigrateTarget,
profile: Option<&str>,
wait_override: Option<u64>,
) {
eprintln!("\u{1F342} autumn migrate\n");
match action {
MigrateAction::Check => {
let migrations_dir = resolve_migrations_dir();
run_safety_check(&migrations_dir, profile);
return;
}
MigrateAction::Down(args) => {
run_down(args, with_maintenance, target, profile);
return;
}
MigrateAction::Baseline(args) => {
run_baseline(args, target, profile);
return;
}
_ => {}
}
let env = os_env_with_dotenv_or_exit();
let (targets, config_table) = resolve_targets(target, profile, &env);
let migrations_dir = resolve_migrations_dir();
if !all_targets_sqlite(&targets) {
check_diesel_cli();
}
if with_maintenance && *action == MigrateAction::Run {
enable_maintenance_for_migrate();
}
match action {
MigrateAction::Run => {
let wait = resolve_startup_wait(wait_override, config_table.as_ref());
let lock_policy = resolve_migration_lock_policy_from_sources(
|key| autumn_web::config::Env::var(&env, key),
config_table.as_ref(),
);
run_all_targets(
&targets,
&migrations_dir,
with_maintenance,
wait,
lock_policy,
);
}
MigrateAction::Status => {
let mut sqlite_unsupported = false;
let mut collision_seen = false;
for (label, url) in &targets {
eprintln!("\u{2500}\u{2500} {label} \u{2500}\u{2500}");
if is_sqlite_target(url) {
eprintln!(
" \u{2717} `autumn migrate status` is not supported under the SQLite \
backend. The #2058 port covers `autumn migrate` (up) and \
`autumn migrate down` only \u{2014} run those to apply or roll back a \
`sqlite://` database."
);
eprintln!();
sqlite_unsupported = true;
continue;
}
if let Err(message) = framework_migrations_backend_mismatch(url) {
eprintln!(" \u{2717} {message}");
eprintln!();
sqlite_unsupported = true;
continue;
}
let is_shard = label.starts_with("shard:");
if let Some(message) = framework_version_collision_error(
std::path::Path::new(&migrations_dir),
is_shard,
) {
eprintln!(
" \u{2717} Migration version collision, so the status below cannot \
tell the two apart: {message}"
);
eprintln!();
collision_seen = true;
}
show_status(url, &migrations_dir);
show_rollback_availability(url, &migrations_dir);
show_framework_status(url, is_shard);
eprintln!();
}
if sqlite_unsupported || collision_seen {
std::process::exit(1);
}
}
MigrateAction::Check | MigrateAction::Down(_) | MigrateAction::Baseline(_) => {
unreachable!("handled above")
}
}
}
fn os_env_with_dotenv_or_exit() -> autumn_web::dotenv::DotenvOsEnv {
match autumn_web::dotenv::os_env_with_dotenv() {
Ok(env) => env,
Err(e) => {
eprintln!(" \u{274C} .env: {e}");
std::process::exit(1);
}
}
}
fn resolve_targets(
target: &MigrateTarget,
profile: Option<&str>,
env: &dyn autumn_web::config::Env,
) -> (Vec<(String, String)>, Option<toml::Table>) {
let effective = effective_profile(profile);
let config_table = read_autumn_toml_table_with_profile(Some(&effective));
let control =
resolve_primary_database_url_from_sources(|key| env.var(key), config_table.as_ref());
let shards =
resolve_shard_database_urls_from_sources(|key| env.var(key), config_table.as_ref());
if let Err(message) = reject_sqlite_sharding_topology(control.as_deref(), &shards) {
eprintln!("{message}");
std::process::exit(1);
}
match build_targets(control, shards, target) {
Ok(targets) => (targets, config_table),
Err(message) => {
eprintln!("{message}");
if matches!(target, MigrateTarget::All | MigrateTarget::ControlOnly) {
resolve_database_url();
}
std::process::exit(1);
}
}
}
fn reject_sqlite_sharding_topology(
control: Option<&str>,
shards: &[(String, String)],
) -> Result<(), String> {
if !shards.is_empty() && control.is_some_and(is_sqlite_target) {
return Err(
"\u{2717} SQLite deployments do not support sharding. The configured sqlite:// \
control target has shard entries configured, which is a Postgres-only capability \
\u{2014} remove the shard configuration to run on SQLite, or use a Postgres control \
database. Tracking: #1614."
.to_owned(),
);
}
if shards.iter().any(|(_, url)| is_sqlite_target(url)) {
return Err(
"\u{2717} SQLite deployments do not support sharding. A configured shard targets a \
SQLite database, and per-shard migration is a Postgres-only capability \u{2014} \
remove the SQLite shard configuration to run on SQLite, or use Postgres shard \
targets. Tracking: #1614."
.to_owned(),
);
}
Ok(())
}
fn build_targets(
control: Option<String>,
shards: Vec<(String, String)>,
target: &MigrateTarget,
) -> Result<Vec<(String, String)>, String> {
match target {
MigrateTarget::ControlOnly => control
.map(|url| vec![("control".to_owned(), url)])
.ok_or_else(|| "\u{2717} No control database URL found.".to_owned()),
MigrateTarget::Shard(name) => {
let Some((_, url)) = shards.iter().find(|(shard, _)| shard == name) else {
let detail = if shards.is_empty() {
"No [[database.shards]] entries found in autumn.toml or environment.".to_owned()
} else {
let known: Vec<&str> = shards.iter().map(|(n, _)| n.as_str()).collect();
format!("Known shards: {}", known.join(", "))
};
return Err(format!("\u{2717} Unknown shard {name:?}.\n {detail}"));
};
Ok(vec![(format!("shard:{name}"), url.clone())])
}
MigrateTarget::All => {
let mut targets = Vec::new();
if let Some(control_url) = control {
targets.push(("control".to_owned(), control_url));
} else if shards.is_empty() {
return Err("\u{2717} No database URL found.".to_owned());
}
for (name, url) in shards {
targets.push((format!("shard:{name}"), url));
}
reject_duplicate_target_urls(&targets)?;
Ok(targets)
}
}
}
fn reject_duplicate_target_urls(targets: &[(String, String)]) -> Result<(), String> {
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for (label, url) in targets {
if let Some(prev) = seen.insert(url.as_str(), label.as_str()) {
return Err(format!(
"\u{2717} Targets {prev:?} and {label:?} resolve to the same database URL. \
Check profile / AUTUMN_DATABASE__* overrides; migrating or rolling back the \
same database under two labels would apply/revert it twice."
));
}
}
Ok(())
}
fn run_all_targets(
targets: &[(String, String)],
migrations_dir: &str,
with_maintenance: bool,
wait: std::time::Duration,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) {
let mut completed: Vec<&str> = Vec::new();
for (label, url) in targets {
eprintln!("\u{2500}\u{2500} Migrating {label} \u{2500}\u{2500}");
let is_shard = label.starts_with("shard:");
if run_single_target(url, migrations_dir, is_shard, wait, lock_policy) {
completed.push(label);
eprintln!();
} else {
eprintln!();
eprintln!(" Summary:");
for done in &completed {
eprintln!(" \u{2713} {done}");
}
eprintln!(" \u{2717} {label} \u{2014} FAILED (see output above)");
for (pending, _) in targets.iter().skip(completed.len() + 1) {
eprintln!(" \u{2022} {pending} \u{2014} not attempted");
}
eprintln!();
eprintln!(
" Migrations already applied to earlier targets are skipped \
idempotently when you rerun `autumn migrate`."
);
if with_maintenance {
eprintln!(
" \u{26A0}\u{FE0F} Migration failed — maintenance mode left ON for safety."
);
eprintln!(" Fix the migration then run `autumn migrate` to retry.");
eprintln!(" Run `autumn maintenance off` to re-open traffic manually.");
}
std::process::exit(1);
}
}
eprintln!(" Summary:");
for done in completed {
eprintln!(" \u{2713} {done}");
}
if with_maintenance {
disable_maintenance_after_migrate();
}
}
fn enable_maintenance_for_migrate() {
use autumn_web::maintenance::{MAINTENANCE_FLAG_FILE, MaintenanceConfig, MaintenanceState};
let path = std::path::Path::new(MAINTENANCE_FLAG_FILE);
let config = MaintenanceConfig {
message: Some("Database migration in progress. Please try again in a moment.".to_owned()),
..Default::default()
};
match MaintenanceState::save_to_file(path, &config) {
Ok(()) => eprintln!(" \u{26A0}\u{FE0F} Maintenance mode ENABLED (--with-maintenance)"),
Err(e) => {
eprintln!("\u{274C} Failed to enable maintenance mode: {e}");
std::process::exit(1);
}
}
}
fn disable_maintenance_after_migrate() {
use autumn_web::maintenance::{MAINTENANCE_FLAG_FILE, MaintenanceState};
let path = std::path::Path::new(MAINTENANCE_FLAG_FILE);
match MaintenanceState::remove_flag_file(path) {
Ok(_) => eprintln!(" \u{2713} Maintenance mode DISABLED — normal traffic resuming"),
Err(e) => eprintln!("\u{26A0}\u{FE0F} Could not remove maintenance flag: {e}"),
}
}
fn run_single_target(
database_url: &str,
migrations_dir: &str,
is_shard: bool,
wait: std::time::Duration,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> bool {
use autumn_web::migrate::{DEFAULT_LOCK_WAIT_TIMEOUT, hold_migration_lock, wait_for_database};
if is_sqlite_target(database_url) {
return run_single_target_sqlite(database_url, migrations_dir);
}
if let Err(message) = framework_migrations_backend_mismatch(database_url) {
eprintln!("\u{274C} {message}");
return false;
}
if wait > std::time::Duration::ZERO {
eprintln!(
" Waiting up to {}s for database to become reachable…",
wait.as_secs()
);
match wait_for_database(database_url, wait, |attempt, delay| {
eprintln!(
" Database not reachable yet (attempt {attempt}); \
retrying in {}ms\u{2026}",
delay.as_millis(),
);
}) {
Ok(()) => eprintln!(" Database reachable."),
Err(e) => {
eprintln!("\u{274C} {e}");
return false;
}
}
}
if let Some(message) =
framework_version_collision_error(std::path::Path::new(migrations_dir), is_shard)
{
eprintln!("\u{274C} Migration version collision: {message}");
return false;
}
let _lock_guard = match hold_migration_lock(database_url, DEFAULT_LOCK_WAIT_TIMEOUT) {
Ok(guard) => guard,
Err(e) => {
eprintln!("\u{274C} Failed to acquire migration lock: {e}");
return false;
}
};
let dir = std::path::Path::new(migrations_dir);
if !validate_checksums_before_apply(database_url, dir, lock_policy) {
return false;
}
if !run_user_migrations(database_url, dir, lock_policy) {
return false;
}
record_checksums_after_apply(database_url, dir, lock_policy);
let framework_ok = if is_shard {
run_shard_framework_migrations(database_url, lock_policy)
} else {
run_framework_migrations(database_url, lock_policy)
};
if !framework_ok {
return false;
}
true
}
fn run_single_target_sqlite(database_url: &str, migrations_dir: &str) -> bool {
let dir = std::path::Path::new(migrations_dir);
eprintln!(
" Running pending migrations (SQLite, unlocked harness; app set, then framework sets)...\n"
);
match apply_pending_sqlite_cli(database_url, dir) {
Ok(result) if result.applied.is_empty() => {
eprintln!("\u{2713} Migrations are already up to date.");
true
}
Ok(result) => {
for migration in &result.applied {
eprintln!(" Applied {migration}");
}
eprintln!("\n\u{2713} Migrations applied successfully.");
true
}
Err(e) => {
eprintln!("\u{2717} {e}");
false
}
}
}
fn validate_checksums_before_apply(
database_url: &str,
migrations_dir: &std::path::Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> bool {
match autumn_web::migrate::validate_recorded_checksums_against_dir_with_policy(
database_url,
migrations_dir,
lock_policy,
) {
Ok(()) => true,
Err(e) => {
eprintln!("\u{274C} {e}");
false
}
}
}
fn record_checksums_after_apply(
database_url: &str,
migrations_dir: &std::path::Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) {
match autumn_web::migrate::record_checksums_from_dir_with_policy(
database_url,
migrations_dir,
lock_policy,
) {
Ok(0) => {}
Ok(n) => {
eprintln!(
" \u{2713} Recorded content checksum(s) for {n} newly-applied migration(s)."
);
}
Err(e) => {
eprintln!(
" \u{26A0}\u{FE0F} Could not record migration checksums: {e}. Applied \
migrations will show as `unrecorded` until `autumn migrate baseline` is run."
);
}
}
}
fn run_baseline(args: &BaselineArgs, target: &MigrateTarget, profile: Option<&str>) {
let env = os_env_with_dotenv_or_exit();
let (targets, _) = resolve_targets(target, profile, &env);
let migrations_dir = resolve_migrations_dir();
let dir = std::path::Path::new(&migrations_dir);
for (label, url) in &targets {
eprintln!("\u{2500}\u{2500} Baselining {label} \u{2500}\u{2500}");
if let Some(version) = args.force_version.as_deref() {
eprintln!(
" \u{26A0}\u{FE0F} Re-baselining checksum for {version} \u{2014} the new on-disk \
hash will replace the previously recorded one. Other environments running the \
previous content will now report a mismatch."
);
match autumn_web::migrate::rebaseline_checksum_from_dir_locked(url, dir, version, None)
{
Ok(()) => eprintln!(" \u{2713} Re-baselined {version}."),
Err(e) => {
eprintln!("\u{274C} Re-baseline failed for {label}: {e}");
std::process::exit(1);
}
}
} else {
match autumn_web::migrate::record_checksums_from_dir_locked(url, dir, None) {
Ok(0) => eprintln!(" All applied migrations already have recorded checksums."),
Ok(n) => eprintln!(" \u{2713} Recorded checksum(s) for {n} migration(s)."),
Err(e) => {
eprintln!("\u{274C} Baseline failed for {label}: {e}");
std::process::exit(1);
}
}
}
eprintln!();
}
}
fn print_findings(label: &str, name: &str, findings: &[safety::SafetyFinding]) {
if safety::is_safe(findings) {
eprintln!(" \u{2713} {name} [{label}]");
} else {
eprintln!(" \u{2717} {name} [{label}]");
for f in findings {
eprintln!(" \u{2022} {} [{}]", f.operation, f.risk);
eprintln!(" Why: {}", f.why);
eprintln!(" Next: {}", f.next_action);
}
}
}
fn run_safety_check(migrations_dir: &str, profile: Option<&str>) {
let backend = crate::generate::detect_backend_offline(Path::new("."), profile);
let reports = match check_migrations_in_dir_for(backend, Path::new(migrations_dir)) {
Ok(r) => r,
Err(e) => {
eprintln!("\u{2717} Migration safety check failed: {e}");
std::process::exit(1);
}
};
if reports.is_empty() {
eprintln!("\u{2713} No migrations found in {migrations_dir}/");
return;
}
let total = reports.len();
eprintln!(" Scanning {total} migration(s) in {migrations_dir}/...\n");
for report in &reports {
print_findings("up.sql", &report.name, &report.up);
if !report.down.is_empty() {
print_findings("down.sql", &report.name, &report.down);
}
}
let any_unsafe = rolling_deploy_blocked(&reports);
let any_down_unsafe = reports.iter().any(|r| safety::has_unsafe_findings(&r.down));
eprintln!();
if any_unsafe {
eprintln!(
"\u{2717} One or more migrations contain operations that are unsafe for a live \
rolling deploy."
);
eprintln!(" Review the findings above, apply the expand/contract pattern where needed,");
eprintln!(" or coordinate a maintenance window before deploying these migrations.");
std::process::exit(1);
}
eprintln!("\u{2713} All {total} migration(s) are safe for a rolling deploy.");
if any_down_unsafe {
eprintln!(
" Note: some down.sql reverts contain destructive or blocking operations \
(reported above). These only run on `autumn migrate down`, not on deploy."
);
}
}
fn rolling_deploy_blocked(reports: &[MigrationSafetyReport]) -> bool {
reports.iter().any(|r| safety::has_unsafe_findings(&r.up))
}
#[cfg(test)]
pub fn check_migrations_in_dir(dir: &Path) -> Result<Vec<MigrationSafetyReport>, String> {
check_migrations_in_dir_for(autumn_web::config::DatabaseBackend::Postgres, dir)
}
pub fn check_migrations_in_dir_for(
backend: autumn_web::config::DatabaseBackend,
dir: &Path,
) -> Result<Vec<MigrationSafetyReport>, String> {
let postgres = backend == autumn_web::config::DatabaseBackend::Postgres;
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?
.filter_map(std::result::Result::ok)
.filter(|entry| entry.path().is_dir())
.collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
let mut results = Vec::new();
for entry in entries {
let migration_name = entry.file_name().to_string_lossy().into_owned();
let up_sql_path = entry.path().join("up.sql");
if !up_sql_path.exists() {
continue;
}
let up_sql = std::fs::read_to_string(&up_sql_path)
.map_err(|e| format!("cannot read {}: {e}", up_sql_path.display()))?;
let mut up_findings = safety::classify_sql_for(backend, &up_sql);
if postgres {
check_concurrent_index_transaction_opt_out(&up_sql, &entry.path(), &mut up_findings);
}
let down_sql_path = entry.path().join("down.sql");
let down_findings = if down_sql_path.exists() {
let down_sql = std::fs::read_to_string(&down_sql_path)
.map_err(|e| format!("cannot read {}: {e}", down_sql_path.display()))?;
let mut down_findings = safety::classify_sql_for(backend, &down_sql);
if postgres {
check_concurrent_index_transaction_opt_out(
&down_sql,
&entry.path(),
&mut down_findings,
);
}
down_findings
} else {
Vec::new()
};
results.push(MigrationSafetyReport {
name: migration_name,
up: up_findings,
down: down_findings,
});
}
Ok(results)
}
fn check_concurrent_index_transaction_opt_out(
sql: &str,
migration_dir: &Path,
findings: &mut Vec<safety::SafetyFinding>,
) {
if !safety::contains_concurrent_index(sql) {
return;
}
if !migration_opts_out_of_transaction(migration_dir) {
findings.push(safety::SafetyFinding {
operation: "CONCURRENTLY index operation (missing transaction opt-out)".to_owned(),
risk: safety::RiskLevel::PotentiallyBlocking,
why: "`PostgreSQL` rejects `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` \
inside a transaction block. Diesel wraps migrations in a transaction by default, \
so this migration will fail at deploy time unless the transaction is disabled.",
next_action: "Add `run_in_transaction = false` to the migration's `metadata.toml` \
(create the file if absent). Example: \
echo 'run_in_transaction = false' > migrations/<name>/metadata.toml",
});
}
}
fn migration_opts_out_of_transaction(migration_dir: &Path) -> bool {
std::fs::read_to_string(migration_dir.join("metadata.toml"))
.ok()
.and_then(|content| toml::from_str::<toml::Table>(&content).ok())
.and_then(|table| {
table
.get("run_in_transaction")
.and_then(toml::Value::as_bool)
})
.is_some_and(|v| !v)
}
fn resolve_database_url() -> String {
resolve_database_url_with_env(|key| std::env::var(key))
}
fn resolve_database_url_with_env<F>(env_var: F) -> String
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
let config_table = read_autumn_toml_table();
if let Some(url) = resolve_primary_database_url_from_sources(env_var, config_table.as_ref()) {
return url;
}
eprintln!("\u{2717} No database URL found.");
eprintln!(
" Set database.primary_url (or database.url) in autumn.toml, or set AUTUMN_DATABASE__PRIMARY_URL / AUTUMN_DATABASE__URL / DATABASE_URL."
);
std::process::exit(1);
}
pub fn read_autumn_toml_table() -> Option<toml::Table> {
let config_path = Path::new("autumn.toml");
if !config_path.exists() {
return None;
}
std::fs::read_to_string(config_path)
.ok()
.and_then(|contents| toml::from_str::<toml::Table>(&contents).ok())
}
pub fn effective_profile(explicit: Option<&str>) -> String {
let read = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty());
read("AUTUMN_ENV")
.or_else(|| read("AUTUMN_PROFILE"))
.or_else(|| {
explicit
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_owned)
})
.unwrap_or_else(|| {
if std::env::var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
"prod".to_owned()
} else {
"dev".to_owned()
}
})
}
pub fn is_production_profile_name(profile: &str) -> bool {
let normalized = profile.trim().to_ascii_lowercase();
normalized == "prod" || normalized == "production"
}
#[must_use]
pub fn canonical_profile(profile: &str) -> String {
let trimmed = profile.trim();
if trimmed.eq_ignore_ascii_case("production") || trimmed.eq_ignore_ascii_case("prod") {
"prod".to_owned()
} else if trimmed.eq_ignore_ascii_case("development") || trimmed.eq_ignore_ascii_case("dev") {
"dev".to_owned()
} else {
trimmed.to_owned()
}
}
fn profile_lookup_names(profile: &str) -> Vec<String> {
match profile.trim().to_ascii_lowercase().as_str() {
"prod" | "production" => vec!["production".to_owned(), "prod".to_owned()],
"dev" | "development" => vec!["development".to_owned(), "dev".to_owned()],
_ => vec![profile.trim().to_owned()],
}
}
fn profile_file_lookup_names(profile: &str) -> Vec<String> {
let trimmed = profile.trim();
match trimmed.to_ascii_lowercase().as_str() {
"prod" | "production" => {
if trimmed.eq_ignore_ascii_case("production") {
vec!["production".to_owned(), "prod".to_owned()]
} else {
vec!["prod".to_owned(), "production".to_owned()]
}
}
"dev" | "development" => {
if trimmed.eq_ignore_ascii_case("development") {
vec!["development".to_owned(), "dev".to_owned()]
} else {
vec!["dev".to_owned(), "development".to_owned()]
}
}
_ => vec![trimmed.to_owned()],
}
}
pub fn read_autumn_toml_table_with_profile(profile: Option<&str>) -> Option<toml::Table> {
read_autumn_toml_table_with_profile_in(Path::new("."), profile)
}
pub fn read_autumn_toml_table_with_profile_from_config_dir(
profile: Option<&str>,
) -> Option<toml::Table> {
read_autumn_toml_table_with_profile_in(&autumn_toml_base_dir(), profile)
}
fn autumn_toml_base_dir() -> std::path::PathBuf {
autumn_toml_base_dir_from(std::env::var("AUTUMN_MANIFEST_DIR").ok().as_deref())
}
fn autumn_toml_base_dir_from(manifest_dir: Option<&str>) -> std::path::PathBuf {
if let Some(dir) = manifest_dir.map(str::trim).filter(|d| !d.is_empty()) {
let dir = Path::new(dir);
if dir.join("autumn.toml").exists() {
return dir.to_path_buf();
}
}
std::path::PathBuf::from(".")
}
pub fn read_autumn_toml_table_with_profile_in(
dir: &Path,
profile: Option<&str>,
) -> Option<toml::Table> {
read_autumn_toml_table_with_profile_in_using(dir, profile, |path| {
if !path.exists() {
return None;
}
let contents = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to read {}: {e}", path.display());
std::process::exit(1);
});
match toml::from_str::<toml::Table>(&contents) {
Ok(table) => Some(table),
Err(e) => {
eprintln!("\u{2717} Failed to parse {}: {e}", path.display());
std::process::exit(1);
}
}
})
}
pub fn read_optional_toml_table(path: &Path) -> Option<toml::Table> {
std::fs::read_to_string(path)
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
}
pub fn read_autumn_toml_table_with_profile_in_using(
dir: &Path,
profile: Option<&str>,
read_table: impl Fn(&Path) -> Option<toml::Table>,
) -> Option<toml::Table> {
let base = read_table(&dir.join("autumn.toml"));
let Some(profile) = profile.filter(|p| !p.is_empty()) else {
return base;
};
let lookup_names = profile_lookup_names(profile);
let overlay = profile_file_lookup_names(profile)
.iter()
.find_map(|name| read_table(&dir.join(format!("autumn-{name}.toml"))));
if base.is_none() && overlay.is_none() {
return None;
}
let mut merged = base.unwrap_or_default();
for name in &lookup_names {
if let Some(inline) = merged
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(name))
.and_then(toml::Value::as_table)
.cloned()
{
deep_merge_toml(&mut merged, inline);
}
}
if let Some(overlay) = overlay {
deep_merge_toml(&mut merged, overlay);
}
Some(merged)
}
fn deep_merge_toml(base: &mut toml::Table, overlay: toml::Table) {
for (key, overlay_val) in overlay {
match (base.get_mut(&key), overlay_val) {
(Some(toml::Value::Table(base_child)), toml::Value::Table(overlay_child)) => {
deep_merge_toml(base_child, overlay_child);
}
(_, overlay_val) => {
base.insert(key, overlay_val);
}
}
}
}
pub fn resolve_migration_lock_policy(
profile: Option<&str>,
) -> autumn_web::migrate::MigrationLockPolicy {
use autumn_web::config::Env as _;
let effective = effective_profile(profile);
let config_table = read_autumn_toml_table_with_profile(Some(&effective));
let env = os_env_with_dotenv_or_exit();
resolve_migration_lock_policy_from_sources(|key| env.var(key), config_table.as_ref())
}
pub fn resolve_primary_url(profile: Option<&str>) -> Option<String> {
use autumn_web::config::Env as _;
let effective = effective_profile(profile);
let config_table = read_autumn_toml_table_with_profile(Some(&effective));
let env = os_env_with_dotenv_or_exit();
resolve_primary_database_url_from_sources(|key| env.var(key), config_table.as_ref())
}
pub fn resolve_primary_database_url_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> Option<String>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
for var in [
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_DATABASE__URL",
"DATABASE_URL",
] {
if let Ok(url) = env_var(var)
&& !url.is_empty()
{
return Some(url);
}
}
let database = table?.get("database").and_then(toml::Value::as_table)?;
for key in ["primary_url", "url"] {
if let Some(url) = database
.get(key)
.and_then(toml::Value::as_str)
.filter(|url| !url.is_empty())
{
return Some(url.to_owned());
}
}
None
}
pub fn resolve_shard_database_urls_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> Vec<(String, String)>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
try_resolve_shard_database_urls_from_sources(env_var, table).unwrap_or_else(|msg| {
eprintln!("\u{2717} {msg}");
std::process::exit(1);
})
}
pub fn try_resolve_shard_database_urls_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> Result<Vec<(String, String)>, String>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
const MAX_ENV_SHARDS: usize = 64;
let mut shards: Vec<(String, String)> = Vec::new();
if let Some(entries) = table
.and_then(|table| table.get("database"))
.and_then(toml::Value::as_table)
.and_then(|database| database.get("shards"))
.and_then(toml::Value::as_array)
{
for (i, entry) in entries.iter().enumerate() {
let shard = entry
.as_table()
.ok_or_else(|| format!("[[database.shards]] entry {i} is not a table."))?;
let name = shard
.get("name")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
format!("[[database.shards]] entry {i} is missing a string `name`.")
})?;
let url = shard
.get("primary_url")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
format!(
"[[database.shards]] entry {i} ({name:?}) is missing a string \
`primary_url`."
)
})?;
shards.push((name.to_owned(), url.to_owned()));
}
}
for i in 0..MAX_ENV_SHARDS {
let name_var = format!("AUTUMN_DATABASE__SHARDS__{i}__NAME");
let url_var = format!("AUTUMN_DATABASE__SHARDS__{i}__PRIMARY_URL");
if i >= shards.len() {
let (Ok(name), Ok(url)) = (env_var(&name_var), env_var(&url_var)) else {
break;
};
shards.push((name, url));
continue;
}
if let Ok(name) = env_var(&name_var) {
shards[i].0 = name;
}
if let Ok(url) = env_var(&url_var) {
shards[i].1 = url;
}
}
let mut seen = std::collections::HashSet::new();
for (name, _) in &shards {
if !seen.insert(name.as_str()) {
return Err(format!(
"Duplicate shard name {name:?} in [[database.shards]] / \
AUTUMN_DATABASE__SHARDS__* — shard names must be unique."
));
}
}
Ok(shards)
}
pub fn resolve_startup_wait_secs_from_sources<F>(env_var: F, table: Option<&toml::Table>) -> u64
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
if let Ok(val) = env_var("AUTUMN_DATABASE__STARTUP_WAIT_SECS") {
match val.trim().parse::<u64>() {
Ok(n) => return n,
Err(_) => {
eprintln!(
" Warning: AUTUMN_DATABASE__STARTUP_WAIT_SECS={:?} is not a valid \
non-negative integer; falling back to config file / default (0).",
val.trim()
);
}
}
}
table
.and_then(|t| t.get("database"))
.and_then(toml::Value::as_table)
.and_then(|db| db.get("startup_wait_secs"))
.and_then(toml::Value::as_integer)
.map_or(0, |n| {
u64::try_from(n).unwrap_or_else(|_| {
eprintln!(
" Warning: `database.startup_wait_secs = {n}` in autumn.toml is not a \
valid non-negative integer; falling back to 0 (fail-fast)."
);
0
})
})
}
pub fn resolve_migration_lock_policy_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> autumn_web::migrate::MigrationLockPolicy
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
let mut warnings = Vec::new();
let policy = resolve_migration_lock_policy_with_warnings(env_var, table, &mut warnings);
for warning in warnings {
eprintln!(" Warning: {warning}");
}
policy
}
fn resolve_migration_lock_policy_with_warnings<F>(
env_var: F,
table: Option<&toml::Table>,
warnings: &mut Vec<String>,
) -> autumn_web::migrate::MigrationLockPolicy
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
let mut policy = autumn_web::migrate::MigrationLockPolicy::default();
let db = table
.and_then(|t| t.get("database"))
.and_then(toml::Value::as_table);
let toml_timeout = db
.and_then(|db| db.get("migration_lock_timeout"))
.and_then(|value| {
let parsed = match value {
toml::Value::String(text) => autumn_web::config::parse_duration_str(text).ok(),
toml::Value::Integer(ms) => u64::try_from(*ms)
.ok()
.map(std::time::Duration::from_millis),
_ => None,
};
if parsed.is_none() {
warnings.push(format!(
"`database.migration_lock_timeout = {value}` is not a valid duration; \
using the default."
));
}
parsed
});
let env_timeout = env_var("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT")
.ok()
.filter(|text| !text.trim().is_empty())
.and_then(|text| {
let parsed = autumn_web::config::parse_duration_str(text.trim()).ok();
if parsed.is_none() {
warnings.push(format!(
"AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT={text:?} is not a valid duration; \
ignoring it."
));
}
parsed
});
if let Some(timeout) = env_timeout.or(toml_timeout) {
policy.lock_timeout = timeout;
}
let toml_retries = db
.and_then(|db| db.get("migration_lock_retries"))
.and_then(|value| {
let parsed = value.as_integer().and_then(|n| u32::try_from(n).ok());
if parsed.is_none() {
warnings.push(format!(
"`database.migration_lock_retries = {value}` is not a valid count; \
using the default."
));
}
parsed
});
let env_retries = env_var("AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES")
.ok()
.and_then(|text| {
let parsed = text.trim().parse::<u32>().ok();
if parsed.is_none() {
warnings.push(format!(
"AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES={text:?} is not a valid count; \
ignoring it."
));
}
parsed
});
if let Some(retries) = env_retries.or(toml_retries) {
policy.retries = retries;
}
policy
}
#[allow(
clippy::unnecessary_wraps,
reason = "`None` means no PGOPTIONS for the child; callers set it after a pooler refusal"
)]
fn diesel_pgoptions(existing: Option<&str>, lock_timeout: std::time::Duration) -> Option<String> {
let existing = existing.map(str::trim).filter(|e| !e.is_empty());
let ms = u64::try_from(lock_timeout.as_millis())
.unwrap_or(u64::MAX)
.min(i32::MAX.unsigned_abs().into());
let option = format!("-c lock_timeout={ms}");
Some(match existing {
Some(existing) => format!("{existing} {option}"),
None => option,
})
}
fn strip_lock_timeout(existing: Option<&str>) -> Option<String> {
let mut kept: Vec<&str> = Vec::new();
let mut tokens = existing.unwrap_or_default().split_whitespace().peekable();
while let Some(token) = tokens.next() {
if token == "-c"
&& tokens
.peek()
.is_some_and(|next| next.starts_with("lock_timeout="))
{
tokens.next();
} else if !token.starts_with("-clock_timeout=") && !token.starts_with("--lock_timeout=") {
kept.push(token);
}
}
(!kept.is_empty()).then(|| kept.join(" "))
}
fn child_pgoptions(
inherited: Option<&str>,
lock_timeout: std::time::Duration,
non_transactional_pending: bool,
english: bool,
) -> Option<String> {
let options = if non_transactional_pending {
diesel_pgoptions(
strip_lock_timeout(inherited).as_deref(),
std::time::Duration::ZERO,
)
} else {
diesel_pgoptions(inherited, lock_timeout)
};
if english {
options.map(|options| format!("{options} -c lc_messages=C"))
} else {
options
}
}
fn run_user_migrations(
database_url: &str,
dir: &Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> bool {
eprintln!(" Running pending migrations...\n");
let english = autumn_web::migrate::can_set_lc_messages(database_url);
let non_transactional: std::collections::HashSet<String> =
non_transactional_versions(dir).into_iter().collect();
let pending = if non_transactional.is_empty() {
Ok(None)
} else {
pending_versions(database_url, dir, lock_policy)
};
let outcome = match pending {
Err(e) => Err(e),
Ok(Some(pending)) if pending.iter().any(|v| non_transactional.contains(v)) => {
run_diesel_in_batches(
database_url,
dir,
lock_policy,
&migration_batches(&pending, &non_transactional),
english,
)
}
Ok(Some(_)) => run_diesel_with_policy(database_url, dir, lock_policy, true, english),
Ok(None) => run_diesel_with_policy(
database_url,
dir,
lock_policy,
non_transactional.is_empty(),
english,
),
};
match outcome {
Ok(()) => {
eprintln!("\n\u{2713} Migrations applied successfully.");
true
}
Err(MigrationError::Migration(_)) => {
eprintln!(
"\n\u{274C} Migration failed in {}. Check the error output above.",
dir.display()
);
false
}
Err(e) => {
eprintln!("\u{274C} {e}");
false
}
}
}
fn run_diesel_in_batches(
database_url: &str,
dir: &Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
batches: &[(bool, Vec<String>)],
english: bool,
) -> Result<(), MigrationError> {
for (transactional, versions) in batches {
if !transactional {
eprintln!(
" {} has run_in_transaction = false; running it without lock_timeout.",
versions.join(", ")
);
}
let batch_dir = tempfile::TempDir::new().map_err(|e| {
MigrationError::Migration(format!("could not create a migration batch directory: {e}"))
})?;
copy_migration_subset(dir, batch_dir.path(), versions).map_err(|e| {
MigrationError::Migration(format!("could not copy migrations for a batch: {e}"))
})?;
run_diesel_with_policy(
database_url,
batch_dir.path(),
lock_policy,
*transactional,
english,
)?;
}
Ok(())
}
fn run_diesel_with_policy(
database_url: &str,
dir: &Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
transactional: bool,
english: bool,
) -> Result<(), MigrationError> {
let (database_url, url_options) = split_connection_options(database_url);
let database_url = database_url.as_str();
let inherited = url_options
.or_else(|| std::env::var("PGOPTIONS").ok())
.filter(|options| !options.trim().is_empty());
let mut pgoptions = child_pgoptions(
inherited.as_deref(),
lock_policy.lock_timeout,
!transactional,
english,
);
let mut fallback_dir: Option<tempfile::TempDir> = None;
autumn_web::migrate::retry_on_lock_timeout(
lock_policy,
|delay| {
eprintln!(
" A migration timed out waiting for a table lock; retrying in {}ms\u{2026}",
delay.as_millis()
);
std::thread::sleep(delay);
},
|| {
let run_dir = fallback_dir.as_ref().map_or(dir, tempfile::TempDir::path);
match run_diesel_migrations_once(database_url, run_dir, pgoptions.as_deref()) {
Err(MigrationError::Migration(text)) if startup_options_rejected(&text) => {
let base = pooler_fallback_base(inherited.as_deref(), transactional);
let Some(fallback) = pooler_fallback(pgoptions.as_deref(), base.as_deref())
else {
return Err(MigrationError::Migration(text));
};
if !pooler_fallback_is_safe(transactional, || {
autumn_web::migrate::server_lock_timeout_is_off(database_url)
}) {
return Err(MigrationError::Migration(format!(
"{text}\nThe server refused PGOPTIONS (a pooler such as PgBouncer?), so \
lock_timeout=0 cannot be sent, and the role or database sets a \
nonzero lock_timeout. A run_in_transaction = false migration \
(CREATE INDEX CONCURRENTLY) would be cancelled and leave an INVALID \
index. Run migrations against Postgres directly, or set \
lock_timeout = 0 for the migration role."
)));
}
pgoptions = fallback;
if transactional {
let copy = tempfile::TempDir::new().map_err(|e| {
MigrationError::Migration(format!(
"could not create a migration directory for the pooler fallback: {e}"
))
})?;
copy_with_lock_timeout(
dir,
copy.path(),
lock_policy.lock_timeout,
english,
&non_transactional_versions(dir).into_iter().collect(),
)
.map_err(|e| {
MigrationError::Migration(format!(
"could not copy migrations for the pooler fallback: {e}"
))
})?;
fallback_dir = Some(copy);
eprintln!(
" The server refused PGOPTIONS (a pooler such as PgBouncer?); running \
again with lock_timeout set inside each migration's transaction."
);
} else {
eprintln!(
" The server refused PGOPTIONS (a pooler such as PgBouncer?); running \
again without it (the server lock_timeout is already 0)."
);
}
let run_dir = fallback_dir.as_ref().map_or(dir, tempfile::TempDir::path);
run_diesel_migrations_once(database_url, run_dir, pgoptions.as_deref())
}
other => other,
}
},
)
}
fn copy_with_lock_timeout(
src: &Path,
dst: &Path,
lock_timeout: std::time::Duration,
english: bool,
non_transactional: &std::collections::HashSet<String>,
) -> std::io::Result<()> {
let ms = u64::try_from(lock_timeout.as_millis())
.unwrap_or(u64::MAX)
.min(i32::MAX.unsigned_abs().into());
let header = if english {
format!("SET LOCAL lock_timeout = {ms};\nSET LOCAL lc_messages = 'C';\n")
} else {
format!("SET LOCAL lock_timeout = {ms};\n")
};
for entry in std::fs::read_dir(src)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let transactional = !non_transactional.contains(&migration_dir_version(&name));
let target = dst.join(&name);
std::fs::create_dir_all(&target)?;
for file in std::fs::read_dir(entry.path())? {
let file = file?;
if !file.file_type()?.is_file() {
continue;
}
if transactional && file.file_name() == "up.sql" {
let body = std::fs::read_to_string(file.path())?;
std::fs::write(target.join("up.sql"), format!("{header}{body}"))?;
} else {
std::fs::copy(file.path(), target.join(file.file_name()))?;
}
}
}
Ok(())
}
fn split_connection_options(database_url: &str) -> (String, Option<String>) {
if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") {
split_url_options(database_url)
} else {
split_keyword_options(database_url)
}
}
fn split_url_options(url: &str) -> (String, Option<String>) {
let Some((base, query)) = url.split_once('?') else {
return (url.to_owned(), None);
};
let mut options = None;
let mut kept: Vec<&str> = Vec::new();
for pair in query.split('&') {
match pair.strip_prefix("options=") {
Some(value) => {
options = Some(
percent_encoding::percent_decode_str(value)
.decode_utf8_lossy()
.into_owned(),
);
}
None => kept.push(pair),
}
}
if kept.is_empty() {
(base.to_owned(), options)
} else {
(format!("{base}?{}", kept.join("&")), options)
}
}
fn split_keyword_options(conninfo: &str) -> (String, Option<String>) {
fn pairs(conninfo: &str) -> Option<Vec<(&str, String, std::ops::Range<usize>)>> {
let bytes = conninfo.as_bytes();
let mut pairs = Vec::new();
let mut i = 0;
loop {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i == bytes.len() {
return Some(pairs);
}
let start = i;
while i < bytes.len() && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let keyword = &conninfo[start..i];
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if keyword.is_empty() || bytes.get(i) != Some(&b'=') {
return None;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let mut value = Vec::new();
if bytes.get(i) == Some(&b'\'') {
i += 1;
loop {
match bytes.get(i)? {
b'\'' => {
i += 1;
break;
}
b'\\' => {
value.push(*bytes.get(i + 1)?);
i += 2;
}
&b => {
value.push(b);
i += 1;
}
}
}
} else {
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
if bytes[i] == b'\\' {
value.push(*bytes.get(i + 1)?);
i += 2;
} else {
value.push(bytes[i]);
i += 1;
}
}
}
let value = String::from_utf8(value).ok()?;
pairs.push((keyword, value, start..i));
}
}
let Some(pairs) = pairs(conninfo) else {
return (conninfo.to_owned(), None);
};
let mut options = None;
let mut kept: Vec<&str> = Vec::new();
for (keyword, value, range) in pairs {
if keyword == "options" {
options = Some(value);
} else {
kept.push(&conninfo[range]);
}
}
if options.is_none() {
return (conninfo.to_owned(), None);
}
(kept.join(" "), options)
}
fn pooler_fallback_is_safe(
transactional: bool,
server_lock_timeout_is_off: impl FnOnce() -> bool,
) -> bool {
transactional || server_lock_timeout_is_off()
}
fn pooler_fallback_base(inherited: Option<&str>, transactional: bool) -> Option<String> {
if transactional {
inherited.map(str::to_owned)
} else {
strip_lock_timeout(inherited)
}
}
#[allow(
clippy::option_option,
reason = "outer None: no fallback; inner None: run with no PGOPTIONS"
)]
fn pooler_fallback(current: Option<&str>, inherited: Option<&str>) -> Option<Option<String>> {
(current != inherited).then(|| inherited.map(str::to_owned))
}
fn non_transactional_versions(dir: &Path) -> Vec<String> {
let Ok(source) = diesel_migrations::FileBasedMigrations::from_path(dir) else {
return Vec::new();
};
let Ok(migrations) = diesel::migration::MigrationSource::<diesel::pg::Pg>::migrations(&source)
else {
return Vec::new();
};
migrations
.iter()
.filter(|m| !m.metadata().run_in_transaction())
.map(|m| m.name().version().to_string())
.collect()
}
fn pending_versions(
database_url: &str,
dir: &Path,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> Result<Option<Vec<String>>, MigrationError> {
let Ok(source) = diesel_migrations::FileBasedMigrations::from_path(dir) else {
return Ok(None);
};
let read = autumn_web::migrate::retry_on_lock_timeout(
lock_policy,
|delay| {
eprintln!(
" Reading the pending migrations timed out on a table lock; retrying in {}ms\u{2026}",
delay.as_millis()
);
std::thread::sleep(delay);
},
|| {
autumn_web::migrate::pending_migrations_with_lock_timeout(
database_url,
source.clone(),
lock_policy.lock_timeout,
)
},
);
read.map(|mut pending| {
pending.sort();
Some(pending)
})
}
fn migration_batches(
pending: &[String],
non_transactional: &std::collections::HashSet<String>,
) -> Vec<(bool, Vec<String>)> {
let mut batches: Vec<(bool, Vec<String>)> = Vec::new();
for version in pending {
let transactional = !non_transactional.contains(version);
match batches.last_mut() {
Some((kind, versions)) if *kind == transactional => versions.push(version.clone()),
_ => batches.push((transactional, vec![version.clone()])),
}
}
batches
}
fn migration_dir_version(name: &str) -> String {
name.split('_').next().unwrap_or(name).replace('-', "")
}
fn copy_migration_subset(src: &Path, dst: &Path, versions: &[String]) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if !entry.file_type()?.is_dir() || !versions.contains(&migration_dir_version(&name)) {
continue;
}
let target = dst.join(&name);
std::fs::create_dir_all(&target)?;
for file in std::fs::read_dir(entry.path())? {
let file = file?;
if file.file_type()?.is_file() {
std::fs::copy(file.path(), target.join(file.file_name()))?;
}
}
}
Ok(())
}
fn startup_options_rejected(stderr: &str) -> bool {
stderr.contains("unsupported startup parameter")
}
fn run_diesel_migrations_once(
database_url: &str,
dir: &Path,
pgoptions: Option<&str>,
) -> Result<(), MigrationError> {
use std::io::{BufRead as _, Write as _};
let mut command = Command::new("diesel");
command
.args(["migration", "run", "--migration-dir"])
.arg(dir)
.env("DATABASE_URL", database_url)
.stderr(std::process::Stdio::piped());
match pgoptions {
Some(options) => command.env("PGOPTIONS", options),
None => command.env_remove("PGOPTIONS"),
};
let mut child = command.spawn().map_err(|e| {
MigrationError::Connection(format!("failed to run diesel migration run: {e}"))
})?;
let mut captured = String::new();
if let Some(stderr) = child.stderr.take() {
let mut reader = std::io::BufReader::new(stderr);
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line);
let _ = write!(std::io::stderr(), "{text}");
captured.push_str(&text);
}
}
}
}
match child.wait() {
Ok(status) if status.success() => Ok(()),
Ok(_) => Err(MigrationError::Migration(captured)),
Err(e) => Err(MigrationError::Connection(format!(
"failed to wait for diesel migration run: {e}"
))),
}
}
fn resolve_startup_wait(
flag: Option<u64>,
config_table: Option<&toml::Table>,
) -> std::time::Duration {
let secs = flag.unwrap_or_else(|| {
resolve_startup_wait_secs_from_sources(|key| std::env::var(key), config_table)
});
std::time::Duration::from_secs(secs)
}
fn resolve_migrations_dir() -> String {
let dir = Path::new(DEFAULT_MIGRATIONS_DIR);
if !dir.exists() {
eprintln!("\u{2717} Migrations directory not found: {DEFAULT_MIGRATIONS_DIR}/");
eprintln!(" Create it with `diesel setup` or `diesel migration generate <name>`.");
std::process::exit(1);
}
DEFAULT_MIGRATIONS_DIR.to_string()
}
fn check_diesel_cli() {
match Command::new("diesel").arg("--version").output() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
eprintln!(" Using {}", version.trim());
}
_ => {
eprintln!("\u{2717} diesel CLI not found on PATH.");
eprintln!(
" Install it with: cargo install diesel_cli --no-default-features --features postgres"
);
std::process::exit(1);
}
}
}
fn run_framework_migrations(
database_url: &str,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> bool {
eprintln!(" Running pending Autumn framework migrations...\n");
match run_framework_migrations_inner(database_url, |url, migrations| {
autumn_web::migrate::run_pending_with_policy(url, migrations, lock_policy)
}) {
Ok(result) if result.applied.is_empty() => {
eprintln!("\n\u{2713} Framework migrations are up to date.");
true
}
Ok(result) => {
for migration in &result.applied {
eprintln!(" Applied {migration}");
}
eprintln!("\n\u{2713} Framework migrations applied successfully.");
true
}
Err(e) => {
eprintln!("\n\u{2717} Framework migration failed: {e}");
false
}
}
}
fn run_framework_migrations_inner<F>(
database_url: &str,
run_pending: F,
) -> Result<MigrationResult, MigrationError>
where
F: FnOnce(&str, EmbeddedMigrations) -> Result<MigrationResult, MigrationError>,
{
run_pending(database_url, FRAMEWORK_MIGRATIONS)
}
fn run_shard_framework_migrations(
database_url: &str,
lock_policy: autumn_web::migrate::MigrationLockPolicy,
) -> bool {
eprintln!(" Running pending Autumn shard framework migrations...\n");
match run_shard_framework_migrations_inner(database_url, |url| {
autumn_web::migrate::run_pending_shard_framework_migrations_with_policy(url, lock_policy)
}) {
Ok(result) if result.applied.is_empty() => {
eprintln!("\n\u{2713} Shard framework migrations are up to date.");
true
}
Ok(result) => {
for migration in &result.applied {
eprintln!(" Applied {migration}");
}
eprintln!("\n\u{2713} Shard framework migrations applied successfully.");
true
}
Err(e) => {
eprintln!("\n\u{2717} Shard framework migration failed: {e}");
false
}
}
}
fn run_shard_framework_migrations_inner<F>(
database_url: &str,
run_shard: F,
) -> Result<MigrationResult, MigrationError>
where
F: FnOnce(&str) -> Result<MigrationResult, MigrationError>,
{
run_shard(database_url)
}
fn is_production_profile(explicit: Option<&str>) -> bool {
is_production_profile_name(&effective_profile(explicit))
}
fn has_revertable_down_sql(
m: &AppliedUserMigration,
backend: autumn_web::config::DatabaseBackend,
) -> bool {
m.dir.as_ref().is_some_and(|d| {
std::fs::read_to_string(d.join("down.sql"))
.is_ok_and(|sql| safety::has_executable_sql(backend, &sql))
})
}
fn down_sql_concurrent_without_opt_out(m: &AppliedUserMigration) -> bool {
m.dir.as_ref().is_some_and(|d| {
std::fs::read_to_string(d.join("down.sql"))
.is_ok_and(|sql| safety::contains_concurrent_index(&sql))
&& !migration_opts_out_of_transaction(d)
})
}
fn build_rollback_plan(args: &DownArgs, applied: &[AppliedUserMigration]) -> Vec<String> {
let Some(target_version) = args.to.as_deref() else {
let n = args.steps.unwrap_or(1);
return applied
.iter()
.rev()
.take(n)
.map(|m| m.version.clone())
.collect();
};
if !applied.iter().any(|m| m.version == target_version) {
eprintln!("\u{2717} Version {target_version} is not a currently applied user migration.");
eprintln!(" Check `autumn migrate status` to see the applied user migrations.");
std::process::exit(1);
}
applied
.iter()
.filter(|m| m.version.as_str() > target_version)
.rev()
.map(|m| m.version.clone())
.collect()
}
fn run_down(
args: &DownArgs,
with_maintenance: bool,
target: &MigrateTarget,
profile: Option<&str>,
) {
let is_prod = is_production_profile(profile);
if is_prod && !args.yes_i_mean_prod {
eprintln!("\u{2717} Production profile detected.");
eprintln!(" Rolling back migrations in production requires explicit confirmation.");
eprintln!(" Re-run with --yes-i-mean-prod to proceed.");
std::process::exit(1);
}
let env = os_env_with_dotenv_or_exit();
let (targets, _) = resolve_targets(target, profile, &env);
let migrations_dir = resolve_migrations_dir();
let dir = Path::new(&migrations_dir);
let plans: Vec<(String, Vec<String>)> = targets
.iter()
.map(|(label, url)| {
(
label.clone(),
preflight_rollback_target(args, url, dir, label),
)
})
.collect();
reject_divergent_rollback_plans(&plans);
let mut maintenance_enabled = false;
let mut total_reverted = 0usize;
let multi = targets.len() > 1;
for ((label, url), (_, preflighted_plan)) in targets.iter().zip(plans.iter()) {
if multi {
eprintln!("\u{2500}\u{2500} Rolling back {label} \u{2500}\u{2500}");
}
match run_down_target(
args,
url,
dir,
label.starts_with("shard:"),
with_maintenance,
&mut maintenance_enabled,
preflighted_plan,
) {
Ok(n) => {
total_reverted += n;
if multi {
eprintln!();
}
}
Err(e) => {
eprintln!("\n\u{2717} Rollback failed for {label}: {e}");
if maintenance_enabled {
eprintln!(
" \u{26A0}\u{FE0F} Maintenance mode left ON for safety. Investigate, then run \
`autumn maintenance off` to re-open traffic."
);
}
std::process::exit(1);
}
}
}
if total_reverted > 0 {
eprintln!("\n\u{2713} {total_reverted} migration(s) rolled back.");
}
if maintenance_enabled {
disable_maintenance_after_migrate();
}
}
fn check_rollback_plan_revertable(
applied: &[AppliedUserMigration],
plan: &[String],
backend: autumn_web::config::DatabaseBackend,
) {
let missing: Vec<&str> = plan
.iter()
.filter_map(|version| {
let m = applied.iter().find(|m| &m.version == version)?;
(!has_revertable_down_sql(m, backend)).then_some(m.name.as_str())
})
.collect();
if !missing.is_empty() {
eprintln!(
"\u{2717} The following migration(s) have no executable down.sql and cannot be reverted:"
);
for name in &missing {
eprintln!(" \u{2022} {name}");
}
eprintln!(" Add a down.sql to each migration before running `autumn migrate down`.");
std::process::exit(1);
}
let needs_opt_out: Vec<&str> = plan
.iter()
.filter_map(|version| {
let m = applied.iter().find(|m| &m.version == version)?;
down_sql_concurrent_without_opt_out(m).then_some(m.name.as_str())
})
.collect();
if !needs_opt_out.is_empty() {
eprintln!(
"\u{2717} The following migration(s) revert a `CONCURRENTLY` index but do not set \
`run_in_transaction = false` and cannot be reverted safely:"
);
for name in &needs_opt_out {
eprintln!(" \u{2022} {name}");
}
eprintln!(
" `PostgreSQL` rejects `CONCURRENTLY` index operations inside a transaction, so \
the revert would fail partway through. Add `run_in_transaction = false` to each \
migration's metadata.toml before running `autumn migrate down`."
);
std::process::exit(1);
}
}
fn preflight_rollback_target(
args: &DownArgs,
database_url: &str,
dir: &Path,
label: &str,
) -> Vec<String> {
let applied = applied_user_migrations_for_target(database_url, dir).unwrap_or_else(|e| {
eprintln!("\u{2717} Could not preflight rollback for {label}: {e}");
std::process::exit(1);
});
let plan = build_rollback_plan(args, &applied);
check_rollback_plan_revertable(&applied, &plan, target_backend(database_url));
plan
}
fn rollback_plans_diverge(plans: &[(String, Vec<String>)]) -> bool {
match plans.first() {
Some((_, first)) => plans.iter().any(|(_, plan)| plan != first),
None => false,
}
}
fn reject_divergent_rollback_plans(plans: &[(String, Vec<String>)]) {
if !rollback_plans_diverge(plans) {
return;
}
eprintln!("\u{2717} Refusing to roll back: target rollback plans diverge.");
eprintln!(
" Each target's plan is computed from its own applied history, and they differ \u{2014}\n \
likely because a previous multi-target migrate failed partway. Rolling back now\n \
would leave targets at different schema versions (or drop a migration's objects\n \
from a lagging target while keeping them on others)."
);
for (label, plan) in plans {
if plan.is_empty() {
eprintln!(" \u{2022} {label}: nothing to roll back");
} else {
eprintln!(" \u{2022} {label}: {}", plan.join(", "));
}
}
eprintln!(
" Reconcile the targets first (`autumn migrate status` per target), then re-run\n \
scoped with --shard <name> or --control-only to roll back one target at a time."
);
std::process::exit(1);
}
fn run_down_target(
args: &DownArgs,
database_url: &str,
dir: &Path,
is_shard: bool,
with_maintenance: bool,
maintenance_enabled: &mut bool,
preflighted_plan: &[String],
) -> Result<usize, autumn_web::migrate::MigrationError> {
use autumn_web::migrate::MigrationError;
let plan = |applied: &[AppliedUserMigration]| -> Result<Vec<String>, MigrationError> {
let plan = build_rollback_plan(args, applied);
if plan != preflighted_plan {
return Err(MigrationError::Migration(format!(
"rollback plan for this target changed under the lock since preflight \
(a concurrent migrate/down likely altered its applied history). \
Preflighted [{}] but now [{}]. Aborting before mutating this target; \
re-run `autumn migrate down` once migrations are quiesced.",
preflighted_plan.join(", "),
plan.join(", "),
)));
}
if plan.is_empty() {
eprintln!(" \u{2713} Nothing to roll back.");
return Ok(plan);
}
check_rollback_plan_revertable(applied, &plan, target_backend(database_url));
if with_maintenance && !*maintenance_enabled {
enable_maintenance_for_migrate();
*maintenance_enabled = true;
}
eprintln!(" Rolling back {} migration(s)...\n", plan.len());
Ok(plan)
};
let on_reverted = |r: &autumn_web::migrate::RevertedMigration| {
eprintln!(
" \u{2713} Rolled back {} ({}ms)",
r.name,
r.duration.as_millis()
);
};
if is_sqlite_target(database_url) {
revert_user_migrations_sqlite_cli(database_url, dir, plan, on_reverted)
} else {
if let Some(message) = framework_version_collision_error(dir, is_shard) {
return Err(MigrationError::Migration(format!(
"migration version collision: {message}"
)));
}
autumn_web::migrate::revert_user_migrations_locked(
database_url,
dir,
None,
plan,
on_reverted,
)
}
}
fn framework_version_collision_error(migrations_dir: &Path, is_shard: bool) -> Option<String> {
let target = if is_shard {
autumn_web::migrate::FrameworkTarget::Shard
} else {
autumn_web::migrate::FrameworkTarget::Control
};
match autumn_web::migrate::app_framework_version_collisions(migrations_dir, target) {
Ok(collisions) if collisions.is_empty() => None,
Ok(collisions) => Some(
collisions
.iter()
.map(autumn_web::migrate::FrameworkVersionCollision::remedy)
.collect::<Vec<_>>()
.join("\n "),
),
Err(e) => Some(format!(
"could not check {} for version collisions: {e}",
migrations_dir.display()
)),
}
}
#[cfg(feature = "sqlite")]
fn revert_user_migrations_sqlite_cli<P, F>(
database_url: &str,
migrations_dir: &Path,
plan: P,
on_reverted: F,
) -> Result<usize, autumn_web::migrate::MigrationError>
where
P: FnOnce(&[AppliedUserMigration]) -> Result<Vec<String>, autumn_web::migrate::MigrationError>
+ Send,
F: FnMut(&autumn_web::migrate::RevertedMigration) + Send,
{
autumn_web::migrate::revert_user_migrations_sqlite(
database_url,
migrations_dir,
plan,
on_reverted,
)
}
#[cfg(not(feature = "sqlite"))]
fn revert_user_migrations_sqlite_cli<P, F>(
_database_url: &str,
_migrations_dir: &Path,
_plan: P,
_on_reverted: F,
) -> Result<usize, autumn_web::migrate::MigrationError>
where
P: FnOnce(&[AppliedUserMigration]) -> Result<Vec<String>, autumn_web::migrate::MigrationError>,
F: FnMut(&autumn_web::migrate::RevertedMigration),
{
Err(autumn_web::migrate::MigrationError::Migration(
SQLITE_FEATURE_MSG.to_owned(),
))
}
fn show_rollback_availability(database_url: &str, migrations_dir: &str) {
use autumn_web::migrate::applied_user_migrations;
let dir = Path::new(migrations_dir);
let applied = match applied_user_migrations(database_url, dir) {
Ok(v) => v,
Err(e) => {
eprintln!(" \u{26A0}\u{FE0F} Could not check rollback availability: {e}");
return;
}
};
eprintln!(" Rollback availability (user migrations):\n");
if applied.is_empty() {
eprintln!(" No applied user migrations.");
return;
}
for m in &applied {
if m.dir.is_none() {
eprintln!(
" \u{2717} {} (applied but missing locally — not revertable)",
m.name
);
} else if !has_revertable_down_sql(m, target_backend(database_url)) {
eprintln!(
" \u{2717} {} (no executable down.sql — not revertable)",
m.name
);
} else if down_sql_concurrent_without_opt_out(m) {
eprintln!(
" \u{2717} {} (down.sql uses CONCURRENTLY without `run_in_transaction = false` — \
not revertable as-is)",
m.name
);
} else {
eprintln!(" \u{2713} {}", m.name);
}
}
eprintln!();
}
fn show_status(database_url: &str, migrations_dir: &str) {
eprintln!(" Checking migration status...\n");
show_diesel_migration_status(database_url, Path::new(migrations_dir));
show_checksum_status(database_url, Path::new(migrations_dir));
}
fn show_checksum_status(database_url: &str, migrations_dir: &Path) {
eprintln!("\n Checking migration checksum status...\n");
match autumn_web::migrate::checksum_status(database_url, migrations_dir) {
Ok(entries) if entries.is_empty() => {
eprintln!(" No applied migrations.");
}
Ok(entries) => {
let mut changed = 0;
let mut missing = 0;
let mut unrecorded = 0;
for (version, state) in &entries {
match state {
autumn_web::migrate::ChecksumState::Ok => {
eprintln!(" \u{2713} {version} [ok]");
}
autumn_web::migrate::ChecksumState::Changed { recorded, actual } => {
changed += 1;
eprintln!(
" \u{2717} {version} [changed] recorded={recorded} \
actual={actual}"
);
}
autumn_web::migrate::ChecksumState::Missing { recorded } => {
missing += 1;
eprintln!(
" \u{2717} {version} [missing] recorded={recorded} \
(recorded as applied but up.sql is gone \u{2014} possible drift)"
);
}
autumn_web::migrate::ChecksumState::Unrecorded => {
unrecorded += 1;
eprintln!(" \u{2022} {version} [unrecorded]");
}
}
}
if changed > 0 {
eprintln!(
"\n \u{2717} {changed} migration(s) were EDITED after being applied. \
`autumn migrate` will refuse to run until this is resolved. Never edit \
an applied migration \u{2014} add a new one, or re-baseline deliberately \
with `autumn migrate baseline --force <version>`."
);
}
if missing > 0 {
eprintln!(
"\n \u{2717} {missing} migration(s) are recorded as applied but their \
up.sql is missing from the source tree (deleted or renamed after being \
applied). `autumn migrate` will refuse to run until this is resolved. \
Never delete or rename an applied migration \u{2014} add a new one instead."
);
}
if unrecorded > 0 {
eprintln!(
" {unrecorded} migration(s) have no recorded checksum (applied before \
content-hash tracking existed). Run `autumn migrate baseline` to record \
their current hashes."
);
}
}
Err(e) => {
eprintln!(" \u{26A0}\u{FE0F} Could not read checksum status: {e}");
}
}
eprintln!();
}
fn show_framework_status(database_url: &str, is_shard: bool) {
eprintln!(" Checking Autumn framework migration status...\n");
let pending = if is_shard {
autumn_web::migrate::pending_shard_framework_migrations(database_url)
} else {
pending_framework_migrations_inner(database_url, autumn_web::migrate::pending_migrations)
};
match pending {
Ok(pending) if pending.is_empty() => {
eprintln!(" Framework migrations are up to date.");
}
Ok(pending) => {
eprintln!(" Pending Autumn framework migrations:");
for migration in pending {
eprintln!(" {migration}");
}
}
Err(e) => {
eprintln!("\u{2717} Failed to check framework migration status: {e}");
std::process::exit(1);
}
}
}
fn pending_framework_migrations_inner<F>(
database_url: &str,
pending_migrations: F,
) -> Result<Vec<String>, MigrationError>
where
F: FnOnce(&str, EmbeddedMigrations) -> Result<Vec<String>, MigrationError>,
{
pending_migrations(database_url, FRAMEWORK_MIGRATIONS)
}
fn show_diesel_migration_status(database_url: &str, migrations_dir: &Path) {
let status = Command::new("diesel")
.args(["migration", "list", "--migration-dir"])
.arg(migrations_dir)
.env("DATABASE_URL", database_url)
.status();
match status {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!(
"\n\u{2717} Failed to check migration status for {}.",
migrations_dir.display()
);
std::process::exit(1);
}
Err(e) => {
eprintln!(
"\u{2717} Failed to run diesel migration list for {}: {e}",
migrations_dir.display()
);
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migration_lock_key_exported_from_autumn_web() {
let key = autumn_web::migrate::MIGRATION_ADVISORY_LOCK_KEY;
assert!(key > 0, "lock key must be a positive i64");
}
#[test]
fn default_lock_wait_timeout_is_sixty_seconds() {
let timeout = autumn_web::migrate::DEFAULT_LOCK_WAIT_TIMEOUT;
assert_eq!(timeout.as_secs(), 60);
}
#[test]
fn hold_migration_lock_returns_connection_error_on_bad_url() {
let result = autumn_web::migrate::hold_migration_lock(
"postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db",
std::time::Duration::from_secs(1),
);
assert!(result.is_err());
assert!(
matches!(
result.unwrap_err(),
autumn_web::migrate::MigrationError::Connection(_)
),
"unreachable host must produce Connection error"
);
}
#[test]
fn migrate_action_eq() {
assert_eq!(MigrateAction::Run, MigrateAction::Run);
assert_eq!(MigrateAction::Status, MigrateAction::Status);
assert_eq!(MigrateAction::Check, MigrateAction::Check);
assert_ne!(MigrateAction::Run, MigrateAction::Status);
assert_ne!(MigrateAction::Run, MigrateAction::Check);
}
#[test]
fn baseline_action_default_records_all_unrecorded() {
let action = MigrateAction::Baseline(BaselineArgs {
force_version: None,
});
assert!(matches!(
action,
MigrateAction::Baseline(BaselineArgs {
force_version: None
})
));
}
#[test]
fn baseline_action_force_targets_one_version() {
let action = MigrateAction::Baseline(BaselineArgs {
force_version: Some("20260101000000".to_owned()),
});
match action {
MigrateAction::Baseline(args) => {
assert_eq!(args.force_version.as_deref(), Some("20260101000000"));
}
_ => panic!("expected Baseline"),
}
}
fn migrations_dir_with_up(up: &str) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2026_01_01_000000_m");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("up.sql"), up).unwrap();
tmp
}
#[test]
fn sqlite_dir_scan_does_not_flag_the_drop_index_before_drop_column() {
let tmp = migrations_dir_with_up(
"DROP INDEX IF EXISTS idx_posts_title;\nALTER TABLE posts DROP COLUMN title;\n",
);
let reports =
check_migrations_in_dir_for(autumn_web::config::DatabaseBackend::Sqlite, tmp.path())
.unwrap();
let ops: Vec<&str> = reports[0].up.iter().map(|f| f.operation.as_str()).collect();
assert_eq!(ops, vec!["DROP COLUMN"], "got {ops:?}");
}
#[test]
fn sqlite_dir_scan_skips_the_concurrently_transaction_opt_out_check() {
let tmp = migrations_dir_with_up("CREATE INDEX CONCURRENTLY i ON posts (title);\n");
let reports =
check_migrations_in_dir_for(autumn_web::config::DatabaseBackend::Sqlite, tmp.path())
.unwrap();
let ops: Vec<&str> = reports[0].up.iter().map(|f| f.operation.as_str()).collect();
assert_eq!(
ops,
vec!["CREATE INDEX CONCURRENTLY (unsupported on SQLite)"],
"got {ops:?}"
);
}
#[test]
fn sqlite_dir_scan_classifies_down_sql_too() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2026_01_01_000000_m");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("up.sql"), "CREATE INDEX i ON posts (title);\n").unwrap();
std::fs::write(
dir.join("down.sql"),
"DROP INDEX i;\nALTER TABLE posts ALTER COLUMN title TYPE TEXT;\n",
)
.unwrap();
let reports =
check_migrations_in_dir_for(autumn_web::config::DatabaseBackend::Sqlite, tmp.path())
.unwrap();
let down: Vec<&str> = reports[0]
.down
.iter()
.map(|f| f.operation.as_str())
.collect();
assert_eq!(
down,
vec!["ALTER COLUMN (unsupported on SQLite)"],
"the DROP INDEX must not be flagged, the ALTER COLUMN must be: {down:?}"
);
}
#[test]
fn grade_migrate_check_for_grades_against_the_given_backend() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2026_01_01_000000_m");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("up.sql"),
"DROP INDEX IF EXISTS idx_posts_title;\n",
)
.unwrap();
assert!(
crate::deploy::grade_migrate_check_for(
autumn_web::config::DatabaseBackend::Sqlite,
tmp.path()
)
.passed
);
assert!(
!crate::deploy::grade_migrate_check_for(
autumn_web::config::DatabaseBackend::Postgres,
tmp.path()
)
.passed
);
}
#[test]
fn check_migrations_in_dir_still_classifies_as_postgres() {
let tmp = migrations_dir_with_up("DROP INDEX idx_posts_title;\n");
let bare = check_migrations_in_dir(tmp.path()).unwrap();
let explicit =
check_migrations_in_dir_for(autumn_web::config::DatabaseBackend::Postgres, tmp.path())
.unwrap();
assert_eq!(bare[0].up.len(), explicit[0].up.len());
assert_eq!(bare[0].up[0].operation, "DROP INDEX (non-concurrent)");
}
fn write_migration(dir: &std::path::Path, name: &str, up_sql: &str) {
let migration_dir = dir.join(name);
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(migration_dir.join("up.sql"), up_sql).unwrap();
std::fs::write(migration_dir.join("down.sql"), "").unwrap();
}
fn write_migration_with_down(dir: &std::path::Path, name: &str, up_sql: &str, down_sql: &str) {
let migration_dir = dir.join(name);
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(migration_dir.join("up.sql"), up_sql).unwrap();
std::fs::write(migration_dir.join("down.sql"), down_sql).unwrap();
}
#[test]
fn check_empty_migrations_dir_returns_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert!(results.is_empty());
}
#[test]
fn check_safe_migration_produces_no_findings() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration(
tmp.path(),
"20260101000000_create_posts",
"CREATE TABLE posts (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL);",
);
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "20260101000000_create_posts");
assert!(
results[0].up.is_empty(),
"CREATE TABLE should produce no up findings"
);
assert!(
results[0].down.is_empty(),
"empty down.sql should produce no findings"
);
}
#[test]
fn check_down_sql_findings_are_included_in_report() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration_with_down(
tmp.path(),
"20260101000000_add_column",
"ALTER TABLE posts ADD COLUMN body TEXT;",
"ALTER TABLE posts DROP COLUMN body;",
);
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].up.is_empty(), "ADD COLUMN should be safe in up");
assert_eq!(
results[0].down.len(),
1,
"DROP COLUMN in down.sql should have 1 finding"
);
assert_eq!(results[0].down[0].risk, safety::RiskLevel::Destructive);
}
#[test]
fn check_down_sql_absent_produces_empty_findings() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260101000000_create_posts");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("up.sql"),
"CREATE TABLE posts (id BIGSERIAL);",
)
.unwrap();
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert!(
results[0].down.is_empty(),
"absent down.sql should produce empty findings"
);
}
#[test]
fn rolling_deploy_gate_ignores_destructive_down_sql() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration_with_down(
tmp.path(),
"20260101000000_add_column",
"ALTER TABLE posts ADD COLUMN body TEXT;",
"ALTER TABLE posts DROP COLUMN body;",
);
let reports = check_migrations_in_dir(tmp.path()).unwrap();
assert!(
!reports[0].down.is_empty(),
"precondition: down.sql has a destructive finding"
);
assert!(
!rolling_deploy_blocked(&reports),
"destructive down.sql must not block a forward rolling deploy"
);
}
#[test]
fn rolling_deploy_gate_blocks_on_unsafe_up_sql() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration_with_down(
tmp.path(),
"20260101000000_drop_column",
"ALTER TABLE posts DROP COLUMN body;",
"ALTER TABLE posts ADD COLUMN body TEXT;",
);
let reports = check_migrations_in_dir(tmp.path()).unwrap();
assert!(
rolling_deploy_blocked(&reports),
"unsafe up.sql must block the forward rolling deploy"
);
}
#[test]
fn check_destructive_migration_produces_findings() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration(
tmp.path(),
"20260102000000_remove_body_from_posts",
"ALTER TABLE posts DROP COLUMN body;",
);
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].up.len(), 1);
assert_eq!(results[0].up[0].risk, safety::RiskLevel::Destructive);
}
#[test]
fn check_results_are_sorted_by_migration_name() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration(tmp.path(), "20260103000000_third", "SELECT 1;");
write_migration(tmp.path(), "20260101000000_first", "SELECT 1;");
write_migration(tmp.path(), "20260102000000_second", "SELECT 1;");
let results = check_migrations_in_dir(tmp.path()).unwrap();
let names: Vec<_> = results.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec![
"20260101000000_first",
"20260102000000_second",
"20260103000000_third"
]
);
}
#[test]
fn check_directories_without_up_sql_are_skipped() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("incomplete_migration")).unwrap();
write_migration(tmp.path(), "20260101000000_valid", "SELECT 1;");
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "20260101000000_valid");
}
#[test]
fn check_multiple_migrations_reports_each() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration(
tmp.path(),
"20260101000000_create_posts",
"CREATE TABLE posts (id BIGSERIAL PRIMARY KEY);",
);
write_migration(
tmp.path(),
"20260102000000_remove_body",
"ALTER TABLE posts DROP COLUMN body;",
);
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 2);
assert!(results[0].up.is_empty(), "first migration should be safe");
assert!(
!results[1].up.is_empty(),
"second migration should have findings"
);
}
#[test]
fn check_non_concurrent_index_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
write_migration(
tmp.path(),
"20260103000000_add_index",
"CREATE INDEX idx_posts_title ON posts (title);",
);
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results[0].up.len(), 1);
assert_eq!(
results[0].up[0].risk,
safety::RiskLevel::PotentiallyBlocking
);
}
#[test]
fn effective_profile_prefers_autumn_env_over_legacy_and_flag() {
temp_env::with_vars(
[
("AUTUMN_ENV", Some("prod")),
("AUTUMN_PROFILE", Some("dev")),
("AUTUMN_IS_DEBUG", None),
],
|| {
assert_eq!(effective_profile(Some("staging")), "prod");
},
);
}
#[test]
fn effective_profile_uses_legacy_when_no_autumn_env() {
temp_env::with_vars(
[
("AUTUMN_ENV", None),
("AUTUMN_PROFILE", Some("prod")),
("AUTUMN_IS_DEBUG", None),
],
|| {
assert_eq!(effective_profile(None), "prod");
},
);
}
#[test]
fn effective_profile_uses_explicit_flag_when_no_env() {
temp_env::with_vars(
[
("AUTUMN_ENV", None::<&str>),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", None),
],
|| {
assert_eq!(effective_profile(Some("staging")), "staging");
},
);
}
#[test]
fn effective_profile_defaults_to_dev() {
temp_env::with_vars(
[
("AUTUMN_ENV", None::<&str>),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", None),
],
|| {
assert_eq!(effective_profile(None), "dev");
},
);
}
#[test]
fn effective_profile_release_mode_defaults_to_prod() {
temp_env::with_vars(
[
("AUTUMN_ENV", None),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", Some("0")),
],
|| {
assert_eq!(effective_profile(None), "prod");
},
);
}
#[test]
fn canonical_profile_normalizes_aliases_and_case() {
for input in ["development", "Development", "DEV", "dev", " dev "] {
assert_eq!(canonical_profile(input), "dev", "{input:?}");
}
for input in ["production", "PROD", "Prod", "prod"] {
assert_eq!(canonical_profile(input), "prod", "{input:?}");
}
assert_eq!(canonical_profile("staging"), "staging");
assert_eq!(canonical_profile(" QA "), "QA");
}
#[test]
fn profile_file_lookup_prefers_selected_spelling() {
assert_eq!(
profile_file_lookup_names("prod"),
vec!["prod", "production"]
);
assert_eq!(
profile_file_lookup_names("production"),
vec!["production", "prod"]
);
assert_eq!(profile_file_lookup_names("dev"), vec!["dev", "development"]);
assert_eq!(
profile_file_lookup_names("development"),
vec!["development", "dev"]
);
assert_eq!(profile_file_lookup_names("staging"), vec!["staging"]);
}
#[test]
fn is_production_profile_detects_prod() {
temp_env::with_var("AUTUMN_ENV", Some("prod"), || {
assert!(is_production_profile(None));
});
}
#[test]
fn is_production_profile_detects_production() {
temp_env::with_var("AUTUMN_ENV", Some("production"), || {
assert!(is_production_profile(None));
});
}
#[test]
fn is_production_profile_case_insensitive() {
temp_env::with_var("AUTUMN_ENV", Some("PROD"), || {
assert!(is_production_profile(None));
});
}
#[test]
fn is_production_profile_false_for_dev() {
temp_env::with_var("AUTUMN_ENV", Some("dev"), || {
assert!(!is_production_profile(None));
});
}
#[test]
fn is_production_profile_reads_autumn_profile_legacy() {
temp_env::with_vars(
[("AUTUMN_ENV", None), ("AUTUMN_PROFILE", Some("production"))],
|| {
assert!(is_production_profile(None));
},
);
}
#[test]
fn is_production_profile_empty_autumn_env_falls_back_to_legacy() {
temp_env::with_vars(
[
("AUTUMN_ENV", Some(" ")),
("AUTUMN_PROFILE", Some("prod")),
],
|| {
assert!(is_production_profile(None));
},
);
}
#[test]
fn is_production_profile_debug_zero_without_env_is_prod() {
temp_env::with_vars(
[
("AUTUMN_ENV", None),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", Some("0")),
],
|| {
assert!(is_production_profile(None));
},
);
}
#[test]
fn is_production_profile_debug_one_without_env_is_not_prod() {
temp_env::with_vars(
[
("AUTUMN_ENV", None),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", Some("1")),
],
|| {
assert!(!is_production_profile(None));
},
);
}
#[test]
fn is_production_profile_explicit_env_overrides_debug_signal() {
temp_env::with_vars(
[
("AUTUMN_ENV", Some("dev")),
("AUTUMN_PROFILE", None),
("AUTUMN_IS_DEBUG", Some("0")),
],
|| {
assert!(!is_production_profile(None));
},
);
}
#[test]
fn default_migrations_dir_is_migrations() {
assert_eq!(DEFAULT_MIGRATIONS_DIR, "migrations");
}
fn applied(version: &str) -> AppliedUserMigration {
AppliedUserMigration {
version: version.to_string(),
name: format!("{version}_m"),
dir: Some(std::path::PathBuf::from(format!("migrations/{version}_m"))),
}
}
#[test]
fn framework_version_collision_error_names_both_sides_and_the_remedy() {
let dir = std::env::temp_dir().join(format!(
"autumn-cli-collision-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
let colliding = dir.join("20260907101530_zzz_app");
std::fs::create_dir_all(&colliding).expect("migration dir");
std::fs::write(colliding.join("up.sql"), "SELECT 1;\n").expect("up.sql");
std::fs::write(colliding.join("down.sql"), "SELECT 1;\n").expect("down.sql");
let message =
framework_version_collision_error(&dir, false).expect("a collision is refused");
assert!(
framework_version_collision_error(&dir, true).is_some(),
"the derivation migration reaches shards too"
);
assert!(message.contains("20260907101530_zzz_app"), "{message}");
assert!(
message.contains("20260907101530_create_derivations"),
"{message}"
);
assert!(
message.contains("UPDATE __diesel_schema_migrations"),
"{message}"
);
let own = dir.join("20260101000000_create_widgets");
std::fs::remove_dir_all(&colliding).expect("drop the colliding migration");
std::fs::create_dir_all(&own).expect("migration dir");
std::fs::write(own.join("up.sql"), "SELECT 1;\n").expect("up.sql");
std::fs::write(own.join("down.sql"), "SELECT 1;\n").expect("down.sql");
assert!(framework_version_collision_error(&dir, false).is_none());
assert!(framework_version_collision_error(&dir, true).is_none());
}
#[test]
fn rollback_plans_diverge_detects_mismatched_targets() {
let plan = |label: &str, versions: &[&str]| {
(
label.to_owned(),
versions.iter().map(|v| (*v).to_owned()).collect::<Vec<_>>(),
)
};
assert!(!rollback_plans_diverge(&[
plan("control", &["20260102000000"]),
plan("shard:s0", &["20260102000000"]),
]));
assert!(rollback_plans_diverge(&[
plan("control", &["20260102000000"]),
plan("shard:s0", &["20260101000000"]),
]));
assert!(rollback_plans_diverge(&[
plan("control", &["20260102000000"]),
plan("shard:s0", &[]),
]));
assert!(!rollback_plans_diverge(&[plan(
"shard:s0",
&["20260102000000"]
)]));
assert!(!rollback_plans_diverge(&[]));
}
#[test]
fn build_rollback_plan_default_reverts_single_newest() {
let applied = [applied("20260101000000"), applied("20260102000000")];
let args = DownArgs {
steps: None,
to: None,
yes_i_mean_prod: false,
};
assert_eq!(build_rollback_plan(&args, &applied), ["20260102000000"]);
}
#[test]
fn build_rollback_plan_steps_reverts_n_newest_first() {
let applied = [
applied("20260101000000"),
applied("20260102000000"),
applied("20260103000000"),
];
let args = DownArgs {
steps: Some(2),
to: None,
yes_i_mean_prod: false,
};
assert_eq!(
build_rollback_plan(&args, &applied),
["20260103000000", "20260102000000"]
);
}
#[test]
fn build_rollback_plan_to_reverts_strictly_newer_newest_first() {
let applied = [
applied("20260101000000"),
applied("20260102000000"),
applied("20260103000000"),
];
let args = DownArgs {
steps: None,
to: Some("20260101000000".to_string()),
yes_i_mean_prod: false,
};
assert_eq!(
build_rollback_plan(&args, &applied),
["20260103000000", "20260102000000"]
);
}
#[test]
fn has_revertable_down_sql_false_when_missing_locally() {
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000".to_string(),
dir: None,
};
assert!(!has_revertable_down_sql(
&m,
autumn_web::config::DatabaseBackend::Postgres
));
}
#[test]
fn has_revertable_down_sql_true_for_executable_down() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("20260101000000_create_posts");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("down.sql"), "DROP TABLE posts;").unwrap();
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000_create_posts".to_string(),
dir: Some(dir),
};
assert!(has_revertable_down_sql(
&m,
autumn_web::config::DatabaseBackend::Postgres
));
}
#[test]
fn has_revertable_down_sql_false_for_comment_only_down() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("20260101000000_create_posts");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("down.sql"), "-- nothing to do\n").unwrap();
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000_create_posts".to_string(),
dir: Some(dir),
};
assert!(!has_revertable_down_sql(
&m,
autumn_web::config::DatabaseBackend::Postgres
));
}
fn migration_with_down(
down_sql: &str,
metadata: Option<&str>,
) -> (AppliedUserMigration, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("20260101000000_add_index");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("down.sql"), down_sql).unwrap();
if let Some(meta) = metadata {
std::fs::write(dir.join("metadata.toml"), meta).unwrap();
}
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000_add_index".to_string(),
dir: Some(dir),
};
(m, tmp)
}
#[test]
fn down_concurrent_without_metadata_needs_opt_out() {
let (m, _tmp) = migration_with_down("DROP INDEX CONCURRENTLY idx_posts_title;", None);
assert!(down_sql_concurrent_without_opt_out(&m));
}
#[test]
fn down_concurrent_with_opt_out_is_safe() {
let (m, _tmp) = migration_with_down(
"DROP INDEX CONCURRENTLY idx_posts_title;",
Some("run_in_transaction = false\n"),
);
assert!(!down_sql_concurrent_without_opt_out(&m));
}
#[test]
fn down_without_concurrent_index_is_safe() {
let (m, _tmp) = migration_with_down("DROP TABLE posts;", None);
assert!(!down_sql_concurrent_without_opt_out(&m));
}
#[test]
fn down_concurrent_missing_locally_is_not_flagged_here() {
let m = AppliedUserMigration {
version: "20260101000000".to_string(),
name: "20260101000000_add_index".to_string(),
dir: None,
};
assert!(!down_sql_concurrent_without_opt_out(&m));
}
#[test]
fn framework_api_token_migrations_run_from_embedded_set() {
let mut called = false;
let result = run_framework_migrations_inner(
"postgres://primary/app",
|database_url, embedded_migrations| {
assert_eq!(database_url, "postgres://primary/app");
let _ = embedded_migrations;
called = true;
Ok(autumn_web::migrate::MigrationResult {
applied: vec!["20260512000000_create_api_tokens".to_string()],
})
},
)
.unwrap();
assert!(called);
assert_eq!(
result.applied,
vec!["20260512000000_create_api_tokens".to_string()]
);
}
#[test]
fn framework_api_token_status_uses_embedded_set() {
let mut called = false;
let pending = pending_framework_migrations_inner(
"postgres://primary/app",
|database_url, embedded_migrations| {
assert_eq!(database_url, "postgres://primary/app");
let _ = embedded_migrations;
called = true;
Ok(vec!["20260512000000_create_api_tokens".to_string()])
},
)
.unwrap();
assert!(called);
assert_eq!(
pending,
vec!["20260512000000_create_api_tokens".to_string()]
);
}
#[test]
fn shard_target_applies_only_shard_framework_migrations() {
let mut called = false;
let result =
run_shard_framework_migrations_inner("postgres://shard0/app", |database_url| {
assert_eq!(database_url, "postgres://shard0/app");
called = true;
Ok(autumn_web::migrate::MigrationResult {
applied: vec![
"vh_migration".to_string(),
"commit_hook_migration".to_string(),
],
})
})
.unwrap();
assert!(called, "shard framework migration helper must be called");
assert_eq!(
result.applied,
vec!["vh_migration", "commit_hook_migration"]
);
}
#[test]
fn control_target_still_uses_full_framework_migrations() {
let mut called_with_url = String::new();
let mut called = false;
let result =
run_framework_migrations_inner("postgres://control/app", |database_url, _embedded| {
called_with_url = database_url.to_owned();
called = true;
Ok(autumn_web::migrate::MigrationResult {
applied: vec!["20260512000000_create_api_tokens".to_string()],
})
})
.unwrap();
assert!(
called,
"run_framework_migrations_inner must call the closure"
);
assert_eq!(called_with_url, "postgres://control/app");
assert_eq!(
result.applied,
vec!["20260512000000_create_api_tokens".to_string()]
);
}
#[test]
fn embedded_framework_migrations_include_durable_hook_queue() {
use autumn_web::reexports::diesel::migration::{Migration, MigrationSource};
use autumn_web::reexports::diesel::pg::Pg;
let migrations: Vec<Box<dyn Migration<Pg>>> = autumn_web::migrate::FRAMEWORK_MIGRATIONS
.migrations()
.unwrap();
let names: Vec<_> = migrations
.iter()
.map(|migration| migration.name().to_string())
.collect();
assert!(
names
.iter()
.any(|name| name == "20260512000000_create_api_tokens"),
"framework migrations must include the timestamped API token schema migration: {names:?}"
);
assert!(
names
.iter()
.any(|name| name == "20260515000000_create_repository_commit_hook_queue"),
"framework migrations must include the durable repository commit hook queue: {names:?}"
);
assert!(
names
.iter()
.any(|name| name == "20260907101530_create_derivations"),
"framework migrations must include the derivation state table: {names:?}"
);
}
#[test]
fn resolve_database_url_from_env() {
let env_var = |key: &str| -> Result<String, std::env::VarError> {
if key == "AUTUMN_DATABASE__URL" {
Ok("postgres://test:5432/mydb".to_string())
} else {
Err(std::env::VarError::NotPresent)
}
};
let url = resolve_database_url_with_env(env_var);
assert_eq!(url, "postgres://test:5432/mydb");
}
#[test]
fn resolve_database_url_from_database_url_env() {
let env_var = |key: &str| -> Result<String, std::env::VarError> {
if key == "DATABASE_URL" {
Ok("postgres://fallback:5432/db".to_string())
} else {
Err(std::env::VarError::NotPresent)
}
};
let url = resolve_database_url_with_env(env_var);
assert_eq!(url, "postgres://fallback:5432/db");
}
#[test]
fn database_topology_primary_env_wins_for_migrations() {
let env_var = |key: &str| -> Result<String, std::env::VarError> {
match key {
"AUTUMN_DATABASE__PRIMARY_URL" => Ok("postgres://primary:5432/app".to_string()),
"AUTUMN_DATABASE__URL" => Ok("postgres://legacy:5432/app".to_string()),
"DATABASE_URL" => Ok("postgres://database-url:5432/app".to_string()),
_ => Err(std::env::VarError::NotPresent),
}
};
let url = resolve_primary_database_url_from_sources(env_var, None).unwrap();
assert_eq!(url, "postgres://primary:5432/app");
}
#[test]
fn database_topology_toml_primary_wins_over_legacy_url() {
let table = toml::from_str::<toml::Table>(
r#"
[database]
url = "postgres://legacy:5432/app"
primary_url = "postgres://primary:5432/app"
replica_url = "postgres://replica:5432/app"
"#,
)
.unwrap();
let env_var = |_key: &str| -> Result<String, std::env::VarError> {
Err(std::env::VarError::NotPresent)
};
let url = resolve_primary_database_url_from_sources(env_var, Some(&table)).unwrap();
assert_eq!(url, "postgres://primary:5432/app");
}
#[test]
fn resolve_primary_url_reads_env_for_active_profile() {
temp_env::with_vars(
[
("AUTUMN_ENV", Some("dev")),
(
"AUTUMN_DATABASE__PRIMARY_URL",
Some("postgres://primary:5432/app"),
),
],
|| {
assert_eq!(
resolve_primary_url(None).as_deref(),
Some("postgres://primary:5432/app")
);
},
);
}
#[test]
fn resolve_primary_url_none_when_unset() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None),
("DATABASE_URL", None),
],
|| {
assert!(resolve_primary_url(Some("dev")).is_none());
},
);
}
fn two_shards() -> Vec<(String, String)> {
vec![
("shard0".to_owned(), "postgres://s0/app".to_owned()),
("shard1".to_owned(), "postgres://s1/app".to_owned()),
]
}
#[test]
fn build_targets_all_orders_control_first_then_shards() {
let targets = build_targets(
Some("postgres://control/app".to_owned()),
two_shards(),
&MigrateTarget::All,
)
.unwrap();
let labels: Vec<&str> = targets.iter().map(|(label, _)| label.as_str()).collect();
assert_eq!(labels, ["control", "shard:shard0", "shard:shard1"]);
assert_eq!(targets[0].1, "postgres://control/app");
}
#[test]
fn build_targets_all_supports_shard_only_deployments() {
let targets = build_targets(None, two_shards(), &MigrateTarget::All).unwrap();
let labels: Vec<&str> = targets.iter().map(|(label, _)| label.as_str()).collect();
assert_eq!(labels, ["shard:shard0", "shard:shard1"]);
}
#[test]
fn build_targets_all_errors_with_no_databases_at_all() {
let error = build_targets(None, Vec::new(), &MigrateTarget::All).unwrap_err();
assert!(error.contains("No database URL"));
}
fn sqlite_shard() -> Vec<(String, String)> {
vec![("shard0".to_owned(), "sqlite:///tmp/shard0.db".to_owned())]
}
#[test]
fn reject_sqlite_sharding_rejects_sqlite_control_and_shards() {
let err = reject_sqlite_sharding_topology(Some("sqlite:///tmp/app.db"), &two_shards())
.unwrap_err();
assert!(
err.contains("do not support sharding") && err.contains("#1614"),
"guard names the SQLite sharding situation clearly: {err}"
);
}
#[test]
fn reject_sqlite_sharding_rejects_postgres_control_with_sqlite_shard() {
let err = reject_sqlite_sharding_topology(Some("postgres://control/app"), &sqlite_shard())
.unwrap_err();
assert!(
err.contains("do not support sharding")
&& err.contains("shard")
&& err.contains("#1614"),
"guard rejects a sqlite shard target: {err}"
);
}
#[test]
fn reject_sqlite_sharding_rejects_no_control_with_sqlite_shard() {
let err = reject_sqlite_sharding_topology(None, &sqlite_shard()).unwrap_err();
assert!(
err.contains("do not support sharding") && err.contains("#1614"),
"guard rejects a sqlite shard even without a control: {err}"
);
}
#[test]
fn reject_sqlite_sharding_allows_sqlite_without_shards() {
assert_eq!(
reject_sqlite_sharding_topology(Some("sqlite:///tmp/app.db"), &[]),
Ok(())
);
}
#[test]
fn reject_sqlite_sharding_allows_postgres_with_postgres_shards() {
assert_eq!(
reject_sqlite_sharding_topology(Some("postgres://control/app"), &two_shards()),
Ok(())
);
}
#[test]
fn reject_sqlite_sharding_allows_no_control_no_shards() {
assert_eq!(reject_sqlite_sharding_topology(None, &[]), Ok(()));
}
#[test]
fn framework_migrations_backend_mismatch_rejects_non_sqlite_targets_under_a_sqlite_build() {
let is_mismatch = framework_migrations_backend_mismatch("postgres://control/app").is_err();
assert_eq!(
is_mismatch,
cfg!(feature = "sqlite"),
"a `sqlite`-feature build must reject a non-SQLite target; the default build must not"
);
}
#[test]
fn framework_migrations_backend_mismatch_allows_sqlite_targets_in_any_build() {
assert_eq!(
framework_migrations_backend_mismatch("sqlite:///tmp/app.db"),
Ok(())
);
}
#[test]
fn build_targets_control_only_requires_control() {
let targets = build_targets(
Some("postgres://control/app".to_owned()),
two_shards(),
&MigrateTarget::ControlOnly,
)
.unwrap();
assert_eq!(
targets,
vec![("control".to_owned(), "postgres://control/app".to_owned())]
);
let error = build_targets(None, two_shards(), &MigrateTarget::ControlOnly).unwrap_err();
assert!(error.contains("control database"));
}
#[test]
fn build_targets_selects_single_shard_by_name() {
let targets = build_targets(
None,
two_shards(),
&MigrateTarget::Shard("shard1".to_owned()),
)
.unwrap();
assert_eq!(
targets,
vec![("shard:shard1".to_owned(), "postgres://s1/app".to_owned())]
);
}
#[test]
fn build_targets_unknown_shard_lists_known_names() {
let error = build_targets(None, two_shards(), &MigrateTarget::Shard("nope".to_owned()))
.unwrap_err();
assert!(error.contains("Unknown shard"));
assert!(error.contains("shard0, shard1"));
let error =
build_targets(None, Vec::new(), &MigrateTarget::Shard("nope".to_owned())).unwrap_err();
assert!(error.contains("No [[database.shards]] entries"));
}
fn no_env(_key: &str) -> Result<String, std::env::VarError> {
Err(std::env::VarError::NotPresent)
}
#[test]
fn shard_urls_resolve_from_toml_in_declaration_order() {
let table = toml::from_str::<toml::Table>(
r#"
[database]
primary_url = "postgres://control:5432/app"
[[database.shards]]
name = "shard0"
primary_url = "postgres://shard0:5432/app"
[[database.shards]]
name = "shard1"
primary_url = "postgres://shard1:5432/app"
"#,
)
.unwrap();
let shards = resolve_shard_database_urls_from_sources(no_env, Some(&table));
assert_eq!(
shards,
vec![
("shard0".to_owned(), "postgres://shard0:5432/app".to_owned()),
("shard1".to_owned(), "postgres://shard1:5432/app".to_owned()),
]
);
}
#[test]
fn shard_urls_env_overrides_and_appends() {
let table = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "shard0"
primary_url = "postgres://toml:5432/app"
"#,
)
.unwrap();
let env_var = |key: &str| -> Result<String, std::env::VarError> {
match key {
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL" => {
Ok("postgres://env:5432/app".to_owned())
}
"AUTUMN_DATABASE__SHARDS__1__NAME" => Ok("shard1".to_owned()),
"AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL" => {
Ok("postgres://env1:5432/app".to_owned())
}
_ => Err(std::env::VarError::NotPresent),
}
};
let shards = resolve_shard_database_urls_from_sources(env_var, Some(&table));
assert_eq!(
shards,
vec![
("shard0".to_owned(), "postgres://env:5432/app".to_owned()),
("shard1".to_owned(), "postgres://env1:5432/app".to_owned()),
]
);
}
#[test]
fn shard_urls_empty_without_shards() {
assert!(resolve_shard_database_urls_from_sources(no_env, None).is_empty());
}
#[test]
fn try_shard_urls_ok_matches_exiting_variant() {
let table = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "shard0"
primary_url = "postgres://shard0:5432/app"
"#,
)
.unwrap();
assert_eq!(
try_resolve_shard_database_urls_from_sources(no_env, Some(&table)).unwrap(),
vec![("shard0".to_owned(), "postgres://shard0:5432/app".to_owned())],
);
}
#[test]
fn try_shard_urls_missing_primary_url_returns_err_not_exit() {
let table = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "shard0"
"#,
)
.unwrap();
let err = try_resolve_shard_database_urls_from_sources(no_env, Some(&table))
.expect_err("a shard missing primary_url must be an Err, not a process exit");
assert!(
err.contains("primary_url"),
"error must name the missing field: {err}"
);
}
#[test]
fn try_shard_urls_duplicate_name_returns_err() {
let table = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "dup"
primary_url = "postgres://a:5432/app"
[[database.shards]]
name = "dup"
primary_url = "postgres://b:5432/app"
"#,
)
.unwrap();
let err = try_resolve_shard_database_urls_from_sources(no_env, Some(&table))
.expect_err("duplicate shard names must be an Err");
assert!(
err.contains("Duplicate shard name"),
"error must flag the duplicate: {err}"
);
}
#[test]
fn deep_merge_toml_overlay_scalar_wins() {
let mut base = toml::from_str::<toml::Table>(
r#"
[database]
primary_url = "postgres://base:5432/app"
pool_size = 5
"#,
)
.unwrap();
let overlay = toml::from_str::<toml::Table>(
r#"
[database]
primary_url = "postgres://prod:5432/app"
"#,
)
.unwrap();
deep_merge_toml(&mut base, overlay);
let database = base
.get("database")
.and_then(toml::Value::as_table)
.unwrap();
assert_eq!(
database.get("primary_url").and_then(toml::Value::as_str),
Some("postgres://prod:5432/app")
);
assert_eq!(
database.get("pool_size").and_then(toml::Value::as_integer),
Some(5)
);
}
#[test]
fn deep_merge_toml_arrays_replaced_wholesale() {
let mut base = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "s0"
primary_url = "postgres://base-s0:5432/app"
"#,
)
.unwrap();
let overlay = toml::from_str::<toml::Table>(
r#"
[[database.shards]]
name = "s0"
primary_url = "postgres://prod-s0:5432/app"
"#,
)
.unwrap();
deep_merge_toml(&mut base, overlay);
let shards = resolve_shard_database_urls_from_sources(no_env, Some(&base));
assert_eq!(
shards,
vec![("s0".to_owned(), "postgres://prod-s0:5432/app".to_owned())]
);
}
#[test]
fn profile_overlay_overrides_shard_url() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
r#"
[database]
primary_url = "postgres://base-control:5432/app"
[[database.shards]]
name = "s0"
primary_url = "postgres://base-s0:5432/app"
"#,
)
.unwrap();
std::fs::write(
tmp.path().join("autumn-prod.toml"),
r#"
[database]
primary_url = "postgres://prod-control:5432/app"
[[database.shards]]
name = "s0"
primary_url = "postgres://prod-s0:5432/app"
"#,
)
.unwrap();
let with_profile = read_autumn_toml_table_with_profile_in(tmp.path(), Some("prod"));
let without_profile = read_autumn_toml_table_with_profile_in(tmp.path(), None);
let merged = with_profile.unwrap();
assert_eq!(
resolve_primary_database_url_from_sources(no_env, Some(&merged)).as_deref(),
Some("postgres://prod-control:5432/app")
);
assert_eq!(
resolve_shard_database_urls_from_sources(no_env, Some(&merged)),
vec![("s0".to_owned(), "postgres://prod-s0:5432/app".to_owned())]
);
let base = without_profile.unwrap();
assert_eq!(
resolve_primary_database_url_from_sources(no_env, Some(&base)).as_deref(),
Some("postgres://base-control:5432/app")
);
}
#[test]
fn autumn_toml_base_dir_prefers_manifest_dir_with_config() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("autumn.toml"), "[backup.offsite]\n").unwrap();
assert_eq!(
autumn_toml_base_dir_from(Some(tmp.path().to_str().unwrap())),
tmp.path()
);
}
#[test]
fn autumn_toml_base_dir_falls_back_to_cwd() {
assert_eq!(
autumn_toml_base_dir_from(None),
std::path::PathBuf::from(".")
);
assert_eq!(
autumn_toml_base_dir_from(Some(" ")),
std::path::PathBuf::from(".")
);
let empty = tempfile::TempDir::new().unwrap();
assert_eq!(
autumn_toml_base_dir_from(Some(empty.path().to_str().unwrap())),
std::path::PathBuf::from(".")
);
}
#[test]
fn config_dir_read_surfaces_offsite_section() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[backup.offsite]\nauto_upload = true\nprefix = \"db\"\n",
)
.unwrap();
let table = read_autumn_toml_table_with_profile_in(tmp.path(), Some("dev"))
.expect("manifest-dir autumn.toml should read");
let auto_upload = table
.get("backup")
.and_then(|v| v.get("offsite"))
.and_then(|v| v.get("auto_upload"))
.and_then(toml::Value::as_bool);
assert_eq!(auto_upload, Some(true));
}
#[test]
fn overlay_file_resolved_via_profile_alias() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"postgres://base:5432/app\"\n",
)
.unwrap();
std::fs::write(
tmp.path().join("autumn-prod.toml"),
"[database]\nprimary_url = \"postgres://prod:5432/app\"\n",
)
.unwrap();
let merged =
read_autumn_toml_table_with_profile_in(tmp.path(), Some("production")).unwrap();
assert_eq!(
resolve_primary_database_url_from_sources(no_env, Some(&merged)).as_deref(),
Some("postgres://prod:5432/app")
);
}
#[test]
fn inline_profile_section_overrides_base_url() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("autumn.toml"),
r#"
[database]
primary_url = "postgres://base-control:5432/app"
[[database.shards]]
name = "s0"
primary_url = "postgres://base-s0:5432/app"
[profile.production.database]
primary_url = "postgres://prod-control:5432/app"
[[profile.production.database.shards]]
name = "s0"
primary_url = "postgres://prod-s0:5432/app"
"#,
)
.unwrap();
let merged = read_autumn_toml_table_with_profile_in(tmp.path(), Some("prod")).unwrap();
assert_eq!(
resolve_primary_database_url_from_sources(no_env, Some(&merged)).as_deref(),
Some("postgres://prod-control:5432/app")
);
assert_eq!(
resolve_shard_database_urls_from_sources(no_env, Some(&merged)),
vec![("s0".to_owned(), "postgres://prod-s0:5432/app".to_owned())]
);
}
#[test]
fn concurrent_index_without_metadata_toml_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
assert!(
findings[0].operation.contains("CONCURRENTLY"),
"finding should mention CONCURRENTLY"
);
assert!(
findings[0]
.next_action
.contains("run_in_transaction = false"),
"next_action should guide user to metadata.toml"
);
}
#[test]
fn concurrent_index_with_run_in_transaction_false_is_not_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("metadata.toml"),
"run_in_transaction = false\n",
)
.unwrap();
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert!(
findings.is_empty(),
"correctly opted-out CONCURRENTLY should produce no additional findings"
);
}
#[test]
fn concurrent_index_with_run_in_transaction_false_no_spaces_is_not_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("metadata.toml"),
"run_in_transaction=false\n",
)
.unwrap();
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert!(
findings.is_empty(),
"TOML `run_in_transaction=false` (no spaces) should also suppress the finding"
);
}
#[test]
fn concurrent_index_with_commented_out_flag_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("metadata.toml"),
"# run_in_transaction = false\n",
)
.unwrap();
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(
findings.len(),
1,
"a commented-out opt-out should NOT suppress the finding"
);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
}
#[test]
fn concurrent_index_with_metadata_toml_missing_flag_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("metadata.toml"),
"# Diesel migration metadata\n",
)
.unwrap();
let sql = "CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
}
#[test]
fn non_concurrent_index_is_not_flagged_by_opt_out_check() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "CREATE INDEX idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert!(
findings.is_empty(),
"non-CONCURRENTLY index should not be flagged by opt-out check"
);
}
#[test]
fn concurrent_index_in_sql_comment_is_not_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "-- TODO: switch to CREATE INDEX CONCURRENTLY once traffic drops\n\
CREATE INDEX idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert!(
findings.is_empty(),
"a CONCURRENTLY reference inside a SQL comment must not trigger the opt-out check"
);
}
#[test]
fn concurrent_unique_index_without_metadata_toml_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_unique_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_posts_slug ON posts (slug);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
}
#[test]
fn concurrent_index_multiline_without_metadata_toml_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "CREATE INDEX\n CONCURRENTLY idx_posts_title ON posts (title);";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(
findings.len(),
1,
"multi-line CREATE INDEX CONCURRENTLY should be flagged when metadata.toml is absent"
);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
}
#[test]
fn check_migrations_in_dir_flags_concurrent_index_without_metadata() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("up.sql"),
"CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);",
)
.unwrap();
std::fs::write(migration_dir.join("down.sql"), "").unwrap();
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert!(
results[0]
.up
.iter()
.any(|f| f.operation.contains("CONCURRENTLY")),
"missing metadata.toml should produce a CONCURRENTLY finding"
);
}
#[test]
fn drop_index_concurrently_without_metadata_toml_is_flagged() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_drop_index");
std::fs::create_dir_all(&migration_dir).unwrap();
let sql = "DROP INDEX CONCURRENTLY idx_posts_title;";
let mut findings = Vec::new();
check_concurrent_index_transaction_opt_out(sql, &migration_dir, &mut findings);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].risk, safety::RiskLevel::PotentiallyBlocking);
assert!(
findings[0].operation.contains("CONCURRENTLY"),
"finding should mention CONCURRENTLY"
);
}
#[test]
fn resolve_startup_wait_secs_defaults_to_zero() {
let secs =
resolve_startup_wait_secs_from_sources(|_| Err(std::env::VarError::NotPresent), None);
assert_eq!(secs, 0);
}
#[test]
fn resolve_startup_wait_secs_from_toml() {
let mut table = toml::Table::new();
let mut db = toml::Table::new();
db.insert("startup_wait_secs".to_owned(), toml::Value::Integer(45));
table.insert("database".to_owned(), toml::Value::Table(db));
let secs = resolve_startup_wait_secs_from_sources(
|_| Err(std::env::VarError::NotPresent),
Some(&table),
);
assert_eq!(secs, 45);
}
#[test]
fn resolve_startup_wait_secs_env_overrides_toml() {
let mut table = toml::Table::new();
let mut db = toml::Table::new();
db.insert("startup_wait_secs".to_owned(), toml::Value::Integer(10));
table.insert("database".to_owned(), toml::Value::Table(db));
let secs = resolve_startup_wait_secs_from_sources(
|key| {
if key == "AUTUMN_DATABASE__STARTUP_WAIT_SECS" {
Ok("90".to_owned())
} else {
Err(std::env::VarError::NotPresent)
}
},
Some(&table),
);
assert_eq!(secs, 90);
}
#[test]
fn resolve_startup_wait_secs_bad_env_falls_back_to_toml() {
let mut table = toml::Table::new();
let mut db = toml::Table::new();
db.insert("startup_wait_secs".to_owned(), toml::Value::Integer(30));
table.insert("database".to_owned(), toml::Value::Table(db));
let secs = resolve_startup_wait_secs_from_sources(
|key| {
if key == "AUTUMN_DATABASE__STARTUP_WAIT_SECS" {
Ok("not_a_number".to_owned())
} else {
Err(std::env::VarError::NotPresent)
}
},
Some(&table),
);
assert_eq!(secs, 30, "bad env value should fall back to toml");
}
fn db_table(entries: &[(&str, toml::Value)]) -> toml::Table {
let mut db = toml::Table::new();
for (key, value) in entries {
db.insert((*key).to_owned(), value.clone());
}
let mut table = toml::Table::new();
table.insert("database".to_owned(), toml::Value::Table(db));
table
}
#[test]
fn migration_lock_policy_defaults() {
assert_eq!(
resolve_migration_lock_policy_from_sources(no_env, None),
autumn_web::migrate::MigrationLockPolicy::default()
);
}
#[test]
fn migration_lock_policy_from_toml() {
let table = db_table(&[
("migration_lock_timeout", toml::Value::String("2s".into())),
("migration_lock_retries", toml::Value::Integer(1)),
]);
let policy = resolve_migration_lock_policy_from_sources(no_env, Some(&table));
assert_eq!(policy.lock_timeout, std::time::Duration::from_secs(2));
assert_eq!(policy.retries, 1);
let table = db_table(&[("migration_lock_timeout", toml::Value::Integer(750))]);
let policy = resolve_migration_lock_policy_from_sources(no_env, Some(&table));
assert_eq!(policy.lock_timeout, std::time::Duration::from_millis(750));
}
#[test]
fn invalid_toml_migration_lock_values_are_reported() {
let table = db_table(&[
(
"migration_lock_timeout",
toml::Value::String("5secondsx".into()),
),
("migration_lock_retries", toml::Value::Integer(-1)),
]);
let mut warnings = Vec::new();
let policy =
resolve_migration_lock_policy_with_warnings(no_env, Some(&table), &mut warnings);
assert_eq!(policy, autumn_web::migrate::MigrationLockPolicy::default());
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(
warnings[0].contains("migration_lock_timeout"),
"{warnings:?}"
);
assert!(
warnings[1].contains("migration_lock_retries"),
"{warnings:?}"
);
}
#[test]
fn migration_lock_policy_env_overrides_toml() {
let table = db_table(&[
("migration_lock_timeout", toml::Value::String("2s".into())),
("migration_lock_retries", toml::Value::Integer(1)),
]);
let policy = resolve_migration_lock_policy_from_sources(
|key| match key {
"AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT"
| "AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES" => Ok("0".to_owned()),
_ => Err(std::env::VarError::NotPresent),
},
Some(&table),
);
assert_eq!(policy.lock_timeout, std::time::Duration::ZERO);
assert_eq!(policy.retries, 0);
}
#[test]
fn non_transactional_versions_reads_metadata() {
let dir = tempfile::TempDir::new().unwrap();
for (name, metadata) in [
("2026-01-01-000000_plain", None),
(
"2026-01-02-000000_concurrent_index",
Some("run_in_transaction = false\n"),
),
] {
let migration = dir.path().join(name);
std::fs::create_dir_all(&migration).unwrap();
std::fs::write(migration.join("up.sql"), "SELECT 1;").unwrap();
std::fs::write(migration.join("down.sql"), "SELECT 1;").unwrap();
if let Some(metadata) = metadata {
std::fs::write(migration.join("metadata.toml"), metadata).unwrap();
}
}
assert_eq!(
non_transactional_versions(dir.path()),
vec!["20260102000000".to_owned()]
);
}
#[test]
fn migration_batches_group_runs_of_the_same_kind() {
let pending = ["1", "2", "3", "4", "5"].map(str::to_owned).to_vec();
let non_transactional = std::iter::once("3".to_owned()).collect();
assert_eq!(
migration_batches(&pending, &non_transactional),
vec![
(true, vec!["1".to_owned(), "2".to_owned()]),
(false, vec!["3".to_owned()]),
(true, vec!["4".to_owned(), "5".to_owned()]),
]
);
assert_eq!(
migration_batches(&pending[..2], &non_transactional),
vec![(true, vec!["1".to_owned(), "2".to_owned()])]
);
assert!(migration_batches(&[], &non_transactional).is_empty());
}
#[test]
fn copy_migration_subset_keeps_only_the_named_versions() {
let src = tempfile::TempDir::new().unwrap();
for name in ["2026-01-01-000000_one", "2026-01-02-000000_two"] {
let dir = src.path().join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("up.sql"), name).unwrap();
std::fs::write(dir.join("down.sql"), "SELECT 1;").unwrap();
}
let dst = tempfile::TempDir::new().unwrap();
copy_migration_subset(src.path(), dst.path(), &["20260102000000".to_owned()]).unwrap();
let copied: Vec<String> = std::fs::read_dir(dst.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(copied, vec!["2026-01-02-000000_two".to_owned()]);
assert_eq!(
std::fs::read_to_string(dst.path().join("2026-01-02-000000_two/up.sql")).unwrap(),
"2026-01-02-000000_two"
);
}
#[test]
fn pooler_fallback_base_strips_lock_timeout_for_non_transactional_runs() {
let inherited = Some("-c search_path=tenant -c lock_timeout=9000");
assert_eq!(
pooler_fallback_base(inherited, true).as_deref(),
Some("-c search_path=tenant -c lock_timeout=9000")
);
assert_eq!(
pooler_fallback_base(inherited, false).as_deref(),
Some("-c search_path=tenant")
);
assert_eq!(pooler_fallback_base(None, false), None);
}
#[test]
fn pooler_fallback_keeps_the_inherited_options() {
let inherited = Some("-c search_path=tenant");
let composed = Some("-c search_path=tenant -c lock_timeout=5000");
assert_eq!(
pooler_fallback(composed, inherited),
Some(Some("-c search_path=tenant".to_owned()))
);
assert_eq!(pooler_fallback(composed, None), Some(None));
assert_eq!(pooler_fallback(inherited, inherited), None);
}
#[test]
fn rejected_startup_options_are_detected() {
assert!(startup_options_rejected(
"FATAL: unsupported startup parameter: options"
));
assert!(!startup_options_rejected(
"canceling statement due to lock timeout"
));
}
#[test]
fn diesel_pgoptions_adds_lock_timeout() {
let five = std::time::Duration::from_secs(5);
assert_eq!(
diesel_pgoptions(None, five).as_deref(),
Some("-c lock_timeout=5000")
);
assert_eq!(
diesel_pgoptions(Some("-c search_path=app"), five).as_deref(),
Some("-c search_path=app -c lock_timeout=5000")
);
assert_eq!(
diesel_pgoptions(None, std::time::Duration::ZERO).as_deref(),
Some("-c lock_timeout=0")
);
assert_eq!(
diesel_pgoptions(Some("-c search_path=app"), std::time::Duration::ZERO).as_deref(),
Some("-c search_path=app -c lock_timeout=0")
);
}
#[test]
fn strip_lock_timeout_removes_every_spelling() {
assert_eq!(
strip_lock_timeout(Some(
"-c search_path=app -c lock_timeout=5000 -clock_timeout=1 --lock_timeout=2"
))
.as_deref(),
Some("-c search_path=app")
);
assert_eq!(strip_lock_timeout(Some("-c lock_timeout=5000")), None);
assert_eq!(strip_lock_timeout(None), None);
}
#[test]
fn split_url_options_moves_the_url_options_out_of_the_url() {
assert_eq!(
split_url_options(
"postgres://u:p@db:5432/app?sslmode=require&options=-c%20search_path%3Dtenant"
),
(
"postgres://u:p@db:5432/app?sslmode=require".to_owned(),
Some("-c search_path=tenant".to_owned())
)
);
assert_eq!(
split_url_options("postgres://db/app?options=-c%20a%3D1&options=-c%20b%3D2"),
("postgres://db/app".to_owned(), Some("-c b=2".to_owned())),
"the last value wins, as in libpq"
);
assert_eq!(
split_url_options("postgres://db/app?options="),
("postgres://db/app".to_owned(), Some(String::new())),
"an empty value still hides PGOPTIONS from libpq"
);
assert_eq!(
split_url_options("postgres://db/app?sslmode=require"),
("postgres://db/app?sslmode=require".to_owned(), None)
);
}
#[test]
fn the_pooler_fallback_copy_sets_lock_timeout_in_each_transactional_migration() {
let src = tempfile::TempDir::new().expect("src");
for (name, up) in [
(
"2026-01-01-000000_add_column",
"ALTER TABLE t ADD COLUMN c INT;\n",
),
(
"2026-01-02-000000_index",
"CREATE INDEX CONCURRENTLY i ON t (c);\n",
),
] {
let dir = src.path().join(name);
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("up.sql"), up).expect("up");
std::fs::write(dir.join("down.sql"), "SELECT 1;\n").expect("down");
}
std::fs::write(src.path().join("README"), "not a migration").expect("file");
let dst = tempfile::TempDir::new().expect("dst");
let non_transactional = std::iter::once("20260102000000".to_owned()).collect();
copy_with_lock_timeout(
src.path(),
dst.path(),
std::time::Duration::from_secs(5),
false,
&non_transactional,
)
.expect("copy");
let read = |name: &str, file: &str| {
std::fs::read_to_string(dst.path().join(name).join(file)).expect("read")
};
assert_eq!(
read("2026-01-01-000000_add_column", "up.sql"),
"SET LOCAL lock_timeout = 5000;\nALTER TABLE t ADD COLUMN c INT;\n"
);
assert_eq!(
read("2026-01-02-000000_index", "up.sql"),
"CREATE INDEX CONCURRENTLY i ON t (c);\n",
"a non-transactional migration is copied unchanged"
);
assert_eq!(
read("2026-01-01-000000_add_column", "down.sql"),
"SELECT 1;\n"
);
assert!(!dst.path().join("README").exists());
let english = tempfile::TempDir::new().expect("english");
copy_with_lock_timeout(
src.path(),
english.path(),
std::time::Duration::from_secs(5),
true,
&non_transactional,
)
.expect("copy");
assert_eq!(
std::fs::read_to_string(english.path().join("2026-01-01-000000_add_column/up.sql"))
.expect("read"),
"SET LOCAL lock_timeout = 5000;\nSET LOCAL lc_messages = 'C';\n\
ALTER TABLE t ADD COLUMN c INT;\n"
);
}
#[test]
fn a_non_transactional_fallback_needs_the_server_timeout_off() {
assert!(pooler_fallback_is_safe(true, || panic!("not asked")));
assert!(pooler_fallback_is_safe(false, || true));
assert!(!pooler_fallback_is_safe(false, || false));
}
#[test]
fn split_keyword_options_moves_the_options_out_of_the_conninfo() {
assert_eq!(
split_connection_options(
r"host=db password='p?w \' x' options='-c search_path=tenant -c lock_timeout=100' dbname = app"
),
(
r"host=db password='p?w \' x' dbname = app".to_owned(),
Some("-c search_path=tenant -c lock_timeout=100".to_owned())
),
"other pairs stay verbatim, a `?` in a value is not a URI query"
);
assert_eq!(
split_connection_options("host=db options=-ca=1 options='-c b=2'"),
("host=db".to_owned(), Some("-c b=2".to_owned())),
"the last value wins, as in libpq"
);
assert_eq!(
split_connection_options("host=db options=''"),
("host=db".to_owned(), Some(String::new())),
"an empty value still hides PGOPTIONS from libpq"
);
assert_eq!(
split_connection_options("host=db dbname=app"),
("host=db dbname=app".to_owned(), None)
);
assert_eq!(
split_connection_options("host=db options='unterminated"),
("host=db options='unterminated".to_owned(), None),
"a string libpq cannot parse is left alone"
);
assert_eq!(
split_connection_options("postgresql://db/app?options=-c%20a%3D1"),
("postgresql://db/app".to_owned(), Some("-c a=1".to_owned()))
);
}
#[test]
fn url_options_keep_the_framework_lock_timeout() {
let five = std::time::Duration::from_secs(5);
let (_, url_options) = split_url_options(
"postgres://db/app?options=-c%20search_path%3Dtenant%20-c%20lock_timeout%3D100",
);
assert_eq!(
child_pgoptions(url_options.as_deref(), five, false, true).as_deref(),
Some("-c search_path=tenant -c lock_timeout=100 -c lock_timeout=5000 -c lc_messages=C"),
"a later -c wins, so the policy timeout applies"
);
assert_eq!(
child_pgoptions(url_options.as_deref(), five, true, false).as_deref(),
Some("-c search_path=tenant -c lock_timeout=0"),
"a non-transactional run drops the URL's lock_timeout"
);
}
#[test]
fn child_pgoptions_never_sends_lock_timeout_to_a_non_transactional_run() {
let five = std::time::Duration::from_secs(5);
let inherited = Some("-c search_path=app -c lock_timeout=9000");
assert_eq!(
child_pgoptions(inherited, five, true, false).as_deref(),
Some("-c search_path=app -c lock_timeout=0")
);
assert_eq!(
child_pgoptions(inherited, five, false, false).as_deref(),
Some("-c search_path=app -c lock_timeout=9000 -c lock_timeout=5000")
);
}
#[test]
fn child_pgoptions_asks_for_english_messages_when_allowed() {
let five = std::time::Duration::from_secs(5);
assert_eq!(
child_pgoptions(None, five, false, true).as_deref(),
Some("-c lock_timeout=5000 -c lc_messages=C")
);
assert_eq!(
child_pgoptions(None, five, true, true).as_deref(),
Some("-c lock_timeout=0 -c lc_messages=C")
);
}
#[test]
fn check_migrations_in_dir_concurrent_index_with_metadata_is_safe() {
let tmp = tempfile::TempDir::new().unwrap();
let migration_dir = tmp.path().join("20260104000000_add_index");
std::fs::create_dir_all(&migration_dir).unwrap();
std::fs::write(
migration_dir.join("up.sql"),
"CREATE INDEX CONCURRENTLY idx_posts_title ON posts (title);",
)
.unwrap();
std::fs::write(migration_dir.join("down.sql"), "").unwrap();
std::fs::write(
migration_dir.join("metadata.toml"),
"run_in_transaction = false\n",
)
.unwrap();
let results = check_migrations_in_dir(tmp.path()).unwrap();
assert_eq!(results.len(), 1);
assert!(
!results[0]
.up
.iter()
.any(|f| f.operation.contains("CONCURRENTLY")),
"opted-out CONCURRENTLY should not produce a transaction opt-out finding"
);
}
}