use anyhow::{Context, Result, bail};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};
pub const RETENTION: usize = 7;
pub const RETENTION_BYTES: u64 = 3 * 1024 * 1024 * 1024;
pub const PREFIX: &str = "opencrabs.db.pre-migration-";
pub const LATEST: &str = "opencrabs.db.pre-migration-latest";
pub fn snapshot_dir() -> PathBuf {
crate::config::opencrabs_home().join("backups")
}
fn backing_file(conn: &Connection) -> Result<Option<String>> {
let file: String = conn
.query_row("PRAGMA database_list", [], |r| r.get(2))
.context("read PRAGMA database_list")?;
if file.is_empty() || file == ":memory:" {
return Ok(None);
}
if !Path::new(&file).is_file() {
return Ok(None);
}
Ok(Some(file))
}
fn is_empty_or_transient(conn: &Connection) -> Result<bool> {
if backing_file(conn)?.is_none() {
return Ok(true);
}
let tables: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table'",
[],
|r| r.get(0),
)
.context("count sqlite_master rows")?;
Ok(tables == 0)
}
fn chronological_key(path: &Path) -> (u64, u64, u64) {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return (0, 0, 0);
};
let Some(rest) = name.strip_prefix(PREFIX) else {
return (0, 0, 0);
};
let parts: Vec<&str> = rest.split('-').collect();
if parts.len() < 3 || parts[2].len() != 6 {
return (0, 0, 0);
}
let date = parts[1].parse().unwrap_or(0);
let time = parts[2].parse().unwrap_or(0);
let collision = parts.get(3).and_then(|n| n.parse().ok()).unwrap_or(0);
if date == 0 {
return (0, 0, 0);
}
(date, time, collision)
}
fn is_dated_snapshot(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(PREFIX) && n != LATEST)
.unwrap_or(false)
}
pub(crate) fn collect_dated(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut dated: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| is_dated_snapshot(p))
.collect();
dated.sort_by_key(|p| chronological_key(p));
dated
}
fn file_len(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
pub(crate) fn prune_dated(mut dated: Vec<PathBuf>, retention: usize, byte_budget: u64) -> usize {
let mut removed = 0;
while dated.len() > 1
&& (dated.len() > retention || dated.iter().map(|p| file_len(p)).sum::<u64>() > byte_budget)
{
let old = dated.remove(0);
match std::fs::remove_file(&old) {
Ok(()) => removed += 1,
Err(e) => {
tracing::warn!("Could not prune stale snapshot {}: {e}", old.display());
break;
}
}
}
removed
}
fn publish_latest(dated: &Path, latest: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(src), Ok(dst)) = (std::fs::metadata(dated), std::fs::metadata(latest))
&& src.dev() == dst.dev()
&& src.ino() == dst.ino()
{
return Ok(());
}
}
if latest.exists() {
std::fs::remove_file(latest)?;
}
match std::fs::hard_link(dated, latest) {
Ok(()) => Ok(()),
Err(e) if e.raw_os_error() == Some(18) => std::fs::copy(dated, latest).map(|_| ()),
Err(e) => Err(e),
}
}
pub fn snapshot_before_migrations(conn: &Connection, dir: &Path) -> Result<Option<PathBuf>> {
if is_empty_or_transient(conn)? {
return Ok(None);
}
std::fs::create_dir_all(dir)
.with_context(|| format!("create backups dir {}", dir.display()))?;
let user_version: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.context("read user_version")?;
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let mut dated = dir.join(format!("{PREFIX}{user_version}-{stamp}"));
for n in 2.. {
if !dated.exists() {
break;
}
dated = dir.join(format!("{PREFIX}{user_version}-{stamp}-{n}"));
}
conn.execute("VACUUM INTO ?1", [&dated.to_string_lossy()])
.with_context(|| format!("VACUUM INTO {}", dated.display()))?;
let latest = dir.join(LATEST);
if let Err(e) = publish_latest(&dated, &latest) {
tracing::warn!(
"Snapshot {} written, but the -latest alias could not be updated: {e}",
dated.display()
);
}
rotate(dir)?;
Ok(Some(dated))
}
pub fn rotate(dir: &Path) -> Result<usize> {
let dated = collect_dated(dir);
if dated.is_empty() {
return Ok(0);
}
Ok(prune_dated(dated, RETENTION, RETENTION_BYTES))
}
pub fn newest_snapshot(dir: &Path) -> Option<PathBuf> {
collect_dated(dir).pop()
}
pub fn newest_snapshot_note(newest: Option<&Path>) -> String {
match newest {
Some(path) => format!(
"{} (copy it over the database file to restore)",
path.display()
),
None => "none yet: one is written on the next successful startup".to_string(),
}
}
fn refuse(stage: &str, dir: &Path, cause: &str) -> String {
let latest = dir.join(LATEST);
let hint = if latest.exists() {
format!(
"An earlier snapshot is still available at {}.",
latest.display()
)
} else {
format!("No snapshot exists in {} yet.", dir.display())
};
format!(
"Refusing to run database migrations: {stage} ({cause}). \
{hint} Nothing has been written to the database. \
Restore it by copying a snapshot over the database file, or repair the header, \
then start again. Your brain files and config are untouched."
)
}
pub fn refusal_message(dir: &Path, cause: &str) -> String {
refuse("the pre-migration snapshot failed", dir, cause)
}
pub fn needs_pre_migration_snapshot(conn: &Connection, migration_count: i64) -> Result<bool> {
if is_empty_or_transient(conn)? {
return Ok(false);
}
let user_version: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.context("read user_version")?;
if user_version < migration_count {
return Ok(true);
}
match crate::db::migration_heal::heals_would_write(conn, user_version) {
Ok(write) => Ok(write),
Err(_) => Ok(true),
}
}
pub fn needs_pre_migration_snapshot_at(path: &str, migration_count: i64) -> Result<bool> {
if !Path::new(path).exists() {
return Ok(false);
}
let conn = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE) {
Ok(conn) => conn,
Err(_) => return Ok(true),
};
match needs_pre_migration_snapshot(&conn, migration_count) {
Ok(needed) => Ok(needed),
Err(_) => Ok(true),
}
}
pub fn guard(conn: &Connection, dir: &Path) -> Result<()> {
match snapshot_before_migrations(conn, dir) {
Ok(Some(p)) => {
tracing::info!("Pre-migration snapshot: {}", p.display());
Ok(())
}
Ok(None) => Ok(()),
Err(e) => bail!("{}", refusal_message(dir, &e.to_string())),
}
}
pub fn integrity_preflight(path: &str, dir: &Path) -> Result<()> {
let cause = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE) {
Err(e) => format!("the database could not be opened ({e})"),
Ok(conn) => {
match conn.pragma_query_value(None, "integrity_check", |row| row.get::<_, String>(0)) {
Ok(report) if report == "ok" => return Ok(()),
Ok(report) => format!("integrity_check reported {report:?}"),
Err(e) => format!("integrity_check could not run ({e})"),
}
}
};
bail!(
"{}",
refuse("the pre-migration integrity check failed", dir, &cause)
);
}