use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use sqlx::SqlitePool;
use super::helpers::StoreError;
pub(crate) const REQUIRE_BACKUP_ENV: &str = "CQS_MIGRATE_REQUIRE_BACKUP";
pub(crate) const KEEP_BACKUPS: usize = 3;
pub(crate) fn backup_path_for(db_path: &Path, from: i32, to: i32) -> PathBuf {
let dir = db_path.parent().unwrap_or(Path::new("."));
let stem = db_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "index".to_string());
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
dir.join(format!("{}.bak-v{}-v{}-{}.db", stem, from, to, ts))
}
pub(crate) async fn backup_before_migrate(
pool: &SqlitePool,
db_path: &Path,
from: i32,
to: i32,
) -> Result<Option<PathBuf>, StoreError> {
let _span = tracing::info_span!("backup_before_migrate", from, to).entered();
if let Err(e) = sqlx::query("PRAGMA wal_checkpoint(FULL)")
.execute(pool)
.await
{
tracing::warn!(
error = %e,
"wal_checkpoint before migration backup failed (non-fatal)"
);
}
let backup_db = backup_path_for(db_path, from, to);
match copy_triplet(db_path, &backup_db) {
Ok(()) => {
tracing::info!(
backup = %backup_db.display(),
from,
to,
"Migration backup written"
);
Ok(Some(backup_db))
}
Err(e) => {
let require = std::env::var(REQUIRE_BACKUP_ENV)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if require {
tracing::error!(
error = %e,
db = %db_path.display(),
"Migration backup failed and CQS_MIGRATE_REQUIRE_BACKUP=1 is set; aborting"
);
remove_triplet(&backup_db);
Err(e)
} else {
tracing::warn!(
error = %e,
db = %db_path.display(),
"Migration backup failed; proceeding without snapshot \
(set CQS_MIGRATE_REQUIRE_BACKUP=1 to fail instead)"
);
remove_triplet(&backup_db);
Ok(None)
}
}
}
}
pub(crate) fn restore_from_backup(db_path: &Path, backup_db: &Path) -> Result<(), StoreError> {
let _span = tracing::info_span!("restore_from_backup").entered();
copy_triplet(backup_db, db_path)?;
tracing::info!(
db = %db_path.display(),
backup = %backup_db.display(),
"Restored DB from backup after migration failure"
);
Ok(())
}
pub(crate) fn prune_old_backups(db_path: &Path) -> Result<(), StoreError> {
let _span = tracing::info_span!("prune_old_backups").entered();
let dir = match db_path.parent() {
Some(d) => d,
None => return Ok(()),
};
let stem = match db_path.file_stem() {
Some(s) => s.to_string_lossy().into_owned(),
None => return Ok(()),
};
let prefix = format!("{}.bak-v", stem);
let entries = match std::fs::read_dir(dir) {
Ok(it) => it,
Err(e) => {
tracing::warn!(
error = %e,
dir = %dir.display(),
"Failed to read DB dir for backup pruning (non-fatal)"
);
return Ok(());
}
};
let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(n) => n,
None => continue,
};
if !name.starts_with(&prefix) || !name.ends_with(".db") {
continue;
}
let mtime = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(e) => {
tracing::warn!(
error = %e,
path = %path.display(),
"Failed to stat backup file for pruning (skipping)"
);
continue;
}
};
candidates.push((path, mtime));
}
candidates.sort_by(|a, b| b.1.cmp(&a.1));
for (path, _) in candidates.into_iter().skip(KEEP_BACKUPS) {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(
error = %e,
path = %path.display(),
"Failed to remove old backup (non-fatal)"
);
continue;
}
for ext in ["-wal", "-shm"] {
let sidecar = sidecar_path(&path, ext);
if sidecar.exists() {
let _ = std::fs::remove_file(&sidecar);
}
}
tracing::info!(path = %path.display(), "Pruned old migration backup");
}
Ok(())
}
fn copy_triplet(src: &Path, dst: &Path) -> Result<(), StoreError> {
copy_file_atomic(src, dst)?;
for ext in ["-wal", "-shm"] {
let src_side = sidecar_path(src, ext);
let dst_side = sidecar_path(dst, ext);
if src_side.exists() {
copy_file_atomic(&src_side, &dst_side)?;
} else if dst_side.exists() {
let _ = std::fs::remove_file(&dst_side);
}
}
Ok(())
}
fn copy_file_atomic(src: &Path, dst: &Path) -> Result<(), StoreError> {
let dir = dst.parent().unwrap_or(Path::new("."));
let name = dst
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "backup.tmp".to_string());
let suffix = crate::temp_suffix();
let pid = std::process::id();
let tmp_path = dir.join(format!(".{}.{}.{:016x}.tmp", name, pid, suffix));
if let Err(e) = std::fs::copy(src, &tmp_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(StoreError::Io(e));
}
if let Err(e) = crate::fs::atomic_replace(&tmp_path, dst) {
let _ = std::fs::remove_file(&tmp_path);
return Err(StoreError::Io(e));
}
Ok(())
}
fn remove_triplet(db: &Path) {
let _ = std::fs::remove_file(db);
for ext in ["-wal", "-shm"] {
let _ = std::fs::remove_file(sidecar_path(db, ext));
}
}
fn sidecar_path(db: &Path, ext: &str) -> PathBuf {
let mut s = db.as_os_str().to_os_string();
s.push(ext);
PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_path_appends_suffix_to_full_filename() {
let db = Path::new("/tmp/proj/index.db");
assert_eq!(
sidecar_path(db, "-wal"),
Path::new("/tmp/proj/index.db-wal")
);
assert_eq!(
sidecar_path(db, "-shm"),
Path::new("/tmp/proj/index.db-shm")
);
}
#[test]
fn backup_path_for_builds_expected_stem_format() {
let db = Path::new("/tmp/proj/index.db");
let bak = backup_path_for(db, 19, 20);
let name = bak.file_name().unwrap().to_string_lossy().into_owned();
assert!(
name.starts_with("index.bak-v19-v20-"),
"backup path should start with '<stem>.bak-v<from>-v<to>-': got {}",
name
);
assert!(
name.ends_with(".db"),
"backup path must end in .db: {}",
name
);
assert_eq!(bak.parent().unwrap(), Path::new("/tmp/proj"));
}
#[test]
fn copy_file_atomic_copies_bytes() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.db");
let dst = dir.path().join("dst.db");
std::fs::write(&src, b"hello").unwrap();
copy_file_atomic(&src, &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
}
#[test]
fn copy_triplet_copies_all_present_sidecars() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("s.db");
std::fs::write(&src, b"main").unwrap();
std::fs::write(sidecar_path(&src, "-wal"), b"wal").unwrap();
std::fs::write(sidecar_path(&src, "-shm"), b"shm").unwrap();
let dst = dir.path().join("d.db");
copy_triplet(&src, &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"main");
assert_eq!(std::fs::read(sidecar_path(&dst, "-wal")).unwrap(), b"wal");
assert_eq!(std::fs::read(sidecar_path(&dst, "-shm")).unwrap(), b"shm");
}
#[test]
fn copy_triplet_handles_missing_sidecars() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("s.db");
std::fs::write(&src, b"main").unwrap();
let dst = dir.path().join("d.db");
copy_triplet(&src, &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"main");
assert!(!sidecar_path(&dst, "-wal").exists());
assert!(!sidecar_path(&dst, "-shm").exists());
}
#[test]
fn copy_triplet_removes_stale_sidecars_on_dst() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("s.db");
std::fs::write(&src, b"main").unwrap();
let dst = dir.path().join("d.db");
std::fs::write(&dst, b"old").unwrap();
std::fs::write(sidecar_path(&dst, "-wal"), b"stale-wal").unwrap();
copy_triplet(&src, &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"main");
assert!(
!sidecar_path(&dst, "-wal").exists(),
"stale -wal must be removed"
);
}
}