use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use fsqlite_error::{FrankenError, Result};
use fsqlite_vfs::host_fs;
use serde::{Deserialize, Serialize};
use crate::connection::Connection;
pub const MIGRATION_MARKER_SUFFIX: &str = ".fsqlite-migration-state";
pub const PRE_MIGRATION_BACKUP_SUFFIX: &str = ".pre-migration-bak";
pub const SKIP_MIGRATION_ENV: &str = "FRANKENSQLITE_SKIP_MIGRATION";
pub const CURRENT_MIGRATION_VERSION: u32 = 1;
const BACKUP_COMPANION_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationMarker {
pub last_upgrade_version: u32,
pub last_run_at: u64,
pub repairs_applied: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationOutcome {
SkippedMemory,
SkippedOptOut,
AlreadyMigrated,
MarkedAtBirth,
CleanNoRepair,
Repaired { repairs: Vec<String> },
}
fn sidecar_path(db_path: &str, suffix: &str) -> PathBuf {
let mut s = OsString::from(db_path);
s.push(suffix);
PathBuf::from(s)
}
#[must_use]
pub fn migration_marker_path(db_path: &str) -> PathBuf {
sidecar_path(db_path, MIGRATION_MARKER_SUFFIX)
}
#[must_use]
pub fn pre_migration_backup_path(db_path: &str) -> PathBuf {
sidecar_path(db_path, PRE_MIGRATION_BACKUP_SUFFIX)
}
#[must_use]
pub fn read_migration_marker(db_path: &str) -> Option<MigrationMarker> {
let bytes = host_fs::read(&migration_marker_path(db_path)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn opt_out_from_env_value(value: Option<&str>) -> bool {
value == Some("1")
}
fn migration_repair_message(elapsed_secs: f64, backup_path: &Path) -> String {
format!(
"fsqlite: applied migration repairs (took {elapsed_secs:.1}s). Original DB preserved at {}",
backup_path.display()
)
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn write_marker_atomic(db_path: &str, marker: &MigrationMarker) -> Result<()> {
let final_path = migration_marker_path(db_path);
let tmp_path = sidecar_path(db_path, &format!("{MIGRATION_MARKER_SUFFIX}.tmp"));
let json = serde_json::to_vec_pretty(marker)
.map_err(|e| FrankenError::internal(format!("serialize migration marker: {e}")))?;
host_fs::write(&tmp_path, &json)?;
host_fs::rename(&tmp_path, &final_path)
}
fn backup_file_atomic(from: &Path, to: &Path) -> Result<bool> {
if host_fs::metadata(from).is_err() {
return Ok(false);
}
let mut tmp = to.as_os_str().to_owned();
tmp.push(".tmp");
let tmp_path = PathBuf::from(tmp);
host_fs::copy_file(from, &tmp_path)?;
host_fs::rename(&tmp_path, to)?;
Ok(true)
}
fn backup_original(db_path: &str) -> Result<PathBuf> {
let main_backup = pre_migration_backup_path(db_path);
backup_file_atomic(Path::new(db_path), &main_backup)?;
for suffix in BACKUP_COMPANION_SUFFIXES {
let from = sidecar_path(db_path, suffix);
let to = sidecar_path(db_path, &format!("{PRE_MIGRATION_BACKUP_SUFFIX}{suffix}"));
let _ = backup_file_atomic(&from, &to);
}
Ok(main_backup)
}
pub(crate) async fn run_first_open_migration(
conn: &Connection,
storage_was_empty: bool,
) -> MigrationOutcome {
let db_path = conn.path().to_owned();
if db_path == ":memory:" {
return MigrationOutcome::SkippedMemory;
}
if opt_out_from_env_value(std::env::var(SKIP_MIGRATION_ENV).ok().as_deref()) {
return MigrationOutcome::SkippedOptOut;
}
if let Some(marker) = read_migration_marker(&db_path)
&& marker.last_upgrade_version >= CURRENT_MIGRATION_VERSION
{
return MigrationOutcome::AlreadyMigrated;
}
if storage_was_empty {
let marker = MigrationMarker {
last_upgrade_version: CURRENT_MIGRATION_VERSION,
last_run_at: now_unix_secs(),
repairs_applied: Vec::new(),
};
if let Err(err) = write_marker_atomic(&db_path, &marker) {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to stamp migration marker at birth");
}
return MigrationOutcome::MarkedAtBirth;
}
let started = Instant::now();
match conn.validate_database_integrity(false).await {
Ok(()) => {
let mut repairs_applied = Vec::new();
if !conn.orphaned_fts5_content_shadow_names().is_empty() {
if let Err(err) = backup_original(&db_path) {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "could not back up database before reclaiming orphaned FTS5 content shadows; leaving it untouched");
return MigrationOutcome::CleanNoRepair;
}
match conn.reclaim_orphaned_fts5_content_shadows().await {
Ok(dropped) if !dropped.is_empty() => {
repairs_applied
.push(format!("reclaim_orphaned_fts5_content:{}", dropped.len()));
}
Ok(_) => {}
Err(err) => {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "reclaim_orphaned_fts5_content_shadows failed during migration");
}
}
}
let marker = MigrationMarker {
last_upgrade_version: CURRENT_MIGRATION_VERSION,
last_run_at: now_unix_secs(),
repairs_applied: repairs_applied.clone(),
};
if let Err(err) = write_marker_atomic(&db_path, &marker) {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to write migration marker for a clean database");
}
if repairs_applied.is_empty() {
MigrationOutcome::CleanNoRepair
} else {
MigrationOutcome::Repaired {
repairs: repairs_applied,
}
}
}
Err(integrity_err) => {
let backup_path = match backup_original(&db_path) {
Ok(path) => path,
Err(err) => {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "could not back up database before repair; leaving it untouched");
return MigrationOutcome::CleanNoRepair;
}
};
let mut repairs_applied = Vec::new();
match conn.repair_orphaned_pages().await {
Ok(freed) if freed > 0 => {
repairs_applied.push(format!("repair_orphaned_pages:{freed}"));
}
Ok(_) => {}
Err(err) => {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "repair_orphaned_pages failed during migration");
}
}
match conn.reclaim_orphaned_fts5_content_shadows().await {
Ok(dropped) if !dropped.is_empty() => {
repairs_applied.push(format!("reclaim_orphaned_fts5_content:{}", dropped.len()));
}
Ok(_) => {}
Err(err) => {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "reclaim_orphaned_fts5_content_shadows failed during migration");
}
}
let integrity_ok_after = conn.validate_database_integrity(false).await.is_ok();
if !integrity_ok_after {
tracing::warn!(
target: "fsqlite.migration",
db = %db_path,
original = %integrity_err,
"database still fails integrity_check after the migration repair pass; original preserved at the backup"
);
}
let marker = MigrationMarker {
last_upgrade_version: CURRENT_MIGRATION_VERSION,
last_run_at: now_unix_secs(),
repairs_applied: repairs_applied.clone(),
};
if let Err(err) = write_marker_atomic(&db_path, &marker) {
tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to write migration marker after repair");
}
let elapsed = started.elapsed().as_secs_f64();
eprintln!("{}", migration_repair_message(elapsed, &backup_path));
MigrationOutcome::Repaired {
repairs: repairs_applied,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_paths_append_suffix_to_raw_db_path() {
assert_eq!(
migration_marker_path("/tmp/foo.db"),
PathBuf::from("/tmp/foo.db.fsqlite-migration-state")
);
assert_eq!(
pre_migration_backup_path("/tmp/foo.db"),
PathBuf::from("/tmp/foo.db.pre-migration-bak")
);
}
#[test]
fn marker_roundtrips_through_json() {
let marker = MigrationMarker {
last_upgrade_version: CURRENT_MIGRATION_VERSION,
last_run_at: 1_700_000_000,
repairs_applied: vec!["repair_orphaned_pages:3".to_owned()],
};
let json = serde_json::to_vec(&marker).expect("serialize");
let back: MigrationMarker = serde_json::from_slice(&json).expect("deserialize");
assert_eq!(marker, back);
}
#[test]
fn read_missing_marker_is_none() {
assert!(read_migration_marker("/nonexistent/path/to/db-xyzzy").is_none());
}
#[test]
fn opt_out_only_for_exactly_one() {
assert!(opt_out_from_env_value(Some("1")));
assert!(!opt_out_from_env_value(Some("0")));
assert!(!opt_out_from_env_value(Some("true")));
assert!(!opt_out_from_env_value(Some("")));
assert!(!opt_out_from_env_value(None));
}
#[test]
fn repair_message_names_time_and_backup_path() {
let msg = migration_repair_message(2.34, Path::new("/tmp/foo.db.pre-migration-bak"));
assert!(msg.contains("applied migration repairs"), "got: {msg}");
assert!(msg.contains("2.3s"), "one-decimal elapsed seconds; got: {msg}");
assert!(
msg.contains("/tmp/foo.db.pre-migration-bak"),
"names the backup path; got: {msg}"
);
}
}