safe-migrate 0.8.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use std::fs;
use std::io::Read;
use std::path::Path;

use safe_migrate::_internal::db::cache::{CACHE_V7_MAGIC, DbCacheVersioned};

fn run_auto_sync_case(
    database_url: &str,
    expected_role: &str,
    expected_session_role: &str,
    expected_lock_timeout_ms: u64,
    expected_statement_timeout_ms: u64,
    mode: &str,
) {
    let temp_dir = tempfile::tempdir().expect("create live auto-sync temp directory");
    let config_path = temp_dir.path().join("safe-migrate.toml");
    let cache_path = temp_dir.path().join("baseline.cache");
    fs::write(&config_path, "auto_sync = true\nschemas = [\"public\"]\n")
        .expect("write live auto-sync config");

    let mut command = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary");
    command
        .arg(mode)
        .arg("--config")
        .arg(&config_path)
        .arg("--cache")
        .arg(&cache_path)
        .arg("--json")
        .env("DATABASE_URL", database_url);

    match mode {
        "lint" => {
            let migration_path = temp_dir.path().join("migration.sql");
            fs::write(&migration_path, "SET search_path TO public;\n")
                .expect("write lint migration");
            command.arg("--file").arg(migration_path);
        }
        "lint-chain" => {
            let migrations_dir = temp_dir.path().join("migrations");
            fs::create_dir(&migrations_dir).expect("create lint-chain directory");
            fs::write(
                migrations_dir.join("001_first.sql"),
                "SET search_path TO public;\n",
            )
            .expect("write first chain migration");
            fs::write(
                migrations_dir.join("002_second.sql"),
                "SET search_path TO public;\n",
            )
            .expect("write second chain migration");
            command.arg("--dir").arg(migrations_dir);
        }
        other => panic!("unsupported live auto-sync mode: {other}"),
    }

    let output = command.output().expect("run safe-migrate");
    assert!(
        output.status.success(),
        "{mode} failed: stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let report: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("parse auto-sync JSON report");
    assert_eq!(report["baseline"]["auto_sync"], "refreshed");
    assert_eq!(report["baseline"]["status"], "available");
    assert_eq!(
        report["baseline"]["observed_settings"]["lock_timeout_ms"],
        expected_lock_timeout_ms
    );
    assert_eq!(
        report["baseline"]["observed_settings"]["statement_timeout_ms"],
        expected_statement_timeout_ms
    );
    assert_eq!(report["confidence"], "Exact");
    assert!(
        Path::new(&cache_path).is_file(),
        "auto-sync cache was not written"
    );
    let encoded = fs::read(&cache_path).expect("read auto-sync cache");
    let mut decoder = zstd::stream::Decoder::new(encoded.as_slice()).expect("decode cache zstd");
    let mut payload = Vec::new();
    decoder
        .read_to_end(&mut payload)
        .expect("read decoded cache payload");
    let v7_payload = payload
        .strip_prefix(CACHE_V7_MAGIC)
        .expect("auto-sync must write a V7 cache");
    let config = bincode::config::standard().with_variable_int_encoding();
    let (versioned, bytes_read): (DbCacheVersioned, usize) =
        bincode::serde::decode_from_slice(v7_payload, config).expect("decode V7 cache");
    assert_eq!(bytes_read, v7_payload.len());
    let DbCacheVersioned::V7(cache) = versioned else {
        panic!("auto-sync must encode the V7 cache variant");
    };
    assert_eq!(cache.metadata.source_role.as_deref(), Some(expected_role));
    assert_eq!(
        cache.metadata.source_session_role.as_deref(),
        Some(expected_session_role)
    );
    assert!(cache.metadata.source_search_path.is_some());
    assert_eq!(
        cache.metadata.source_lock_timeout_ms,
        expected_lock_timeout_ms
    );
    assert_eq!(
        cache.metadata.source_statement_timeout_ms,
        expected_statement_timeout_ms
    );
    assert!(!cache.roles.is_empty());

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("Automatic cache sync enabled"));
    assert!(!stderr.contains("Automatic cache sync failed"));
}

#[test]
#[ignore = "requires a live local PostgreSQL database via DATABASE_URL"]
fn live_auto_sync_refreshes_lint_and_lint_chain() {
    let database_url =
        std::env::var("DATABASE_URL").expect("DATABASE_URL is required for live auto-sync proof");
    let mut client = postgres::Client::connect(&database_url, postgres::NoTls)
        .expect("connect for current_user oracle");
    let provenance_oracle = client
        .query_one(
            "SELECT current_user, session_user,
                    (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'),
                    (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout')",
            &[],
        )
        .expect("query synchronization provenance oracle");
    let expected_role: String = provenance_oracle.get(0);
    let expected_session_role: String = provenance_oracle.get(1);
    let expected_lock_timeout_ms = u64::try_from(provenance_oracle.get::<_, i64>(2)).unwrap();
    let expected_statement_timeout_ms = u64::try_from(provenance_oracle.get::<_, i64>(3)).unwrap();
    run_auto_sync_case(
        &database_url,
        &expected_role,
        &expected_session_role,
        expected_lock_timeout_ms,
        expected_statement_timeout_ms,
        "lint",
    );
    run_auto_sync_case(
        &database_url,
        &expected_role,
        &expected_session_role,
        expected_lock_timeout_ms,
        expected_statement_timeout_ms,
        "lint-chain",
    );
}