#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
use std::path::{Path, PathBuf};
#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
use crate::error::TalaDbError;
#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
const BACKUP_SUFFIX: &str = "v2.bak";
#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
fn needs_migration(path: &Path) -> bool {
match redb::Database::open(path) {
Ok(_) => false,
Err(e) => {
let msg = e.to_string();
msg.contains("Manual upgrade required") || msg.contains("file is version 2")
}
}
}
#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
pub fn migrate_file_if_needed(path: &Path) -> Result<bool, TalaDbError> {
if !path.exists() || !needs_migration(path) {
return Ok(false);
}
let backup = backup_path(path);
std::fs::copy(path, &backup).map_err(|e| {
TalaDbError::Storage(format!(
"could not back up {} before migrating it: {e}",
path.display()
))
})?;
let mut old = redb2::Database::open(path).map_err(|e| {
TalaDbError::Storage(format!(
"{} looks like a pre-redb-4 database but could not be opened as one: {e} \
(a copy is at {})",
path.display(),
backup.display()
))
})?;
old.upgrade().map_err(|e| {
TalaDbError::Storage(format!(
"could not upgrade {} to the current file format: {e} (the pre-migration \
copy is at {})",
path.display(),
backup.display()
))
})?;
drop(old);
Ok(true)
}
#[cfg(all(feature = "legacy-migration", not(target_arch = "wasm32")))]
fn backup_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".");
name.push(BACKUP_SUFFIX);
PathBuf::from(name)
}
#[cfg(feature = "legacy-migration")]
pub fn upgrade_legacy_backend<B: redb2::StorageBackend>(
backend: B,
) -> Result<bool, crate::error::TalaDbError> {
use crate::error::TalaDbError;
let mut db = redb2::Database::builder()
.create_with_backend(backend)
.map_err(|e| {
TalaDbError::Storage(format!(
"could not open the existing database to upgrade it: {e}"
))
})?;
let upgraded = db.upgrade().map_err(|e| {
TalaDbError::Storage(format!(
"could not upgrade the database to the current format: {e}"
))
})?;
drop(db);
Ok(upgraded)
}
pub const LEGACY_BACKEND_HELP: &str = "this database was written by an older version of TalaDB \
(redb file format 2). Rebuild with the `legacy-migration` feature to upgrade it \
automatically, or export a snapshot with the previous version (`exportSnapshot`) and \
restore it here (`openWithSnapshot`).";
#[must_use]
pub fn is_legacy_format_error(msg: &str) -> bool {
msg.contains("Manual upgrade required") || msg.contains("file is version 2")
}
#[cfg(all(test, feature = "legacy-migration", not(target_arch = "wasm32")))]
mod tests {
use super::*;
use redb2::ReadableTableMetadata as _;
const TBL: redb2::TableDefinition<&[u8], &[u8]> = redb2::TableDefinition::new("books");
fn write_v2(path: &Path, rows: &[(&str, &str)]) {
let db = redb2::Database::create(path).expect("create v2");
let wtx = db.begin_write().expect("write txn");
{
let mut t = wtx.open_table(TBL).expect("open table");
for (k, v) in rows {
t.insert(k.as_bytes(), v.as_bytes()).expect("insert");
}
}
wtx.commit().expect("commit");
}
#[test]
fn a_v2_database_is_detected_and_converted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.redb");
write_v2(&path, &[("k1", "Dune"), ("k2", "Neuromancer")]);
assert!(needs_migration(&path), "a v2 file must be recognised");
assert!(
migrate_file_if_needed(&path).expect("migration should succeed"),
"migration should report that it did something"
);
use redb::ReadableDatabase as _;
use redb::ReadableTableMetadata as _;
let db = redb::Database::open(&path).expect("redb 4 must open the converted file");
let rtx = db.begin_read().expect("read");
let t = rtx
.open_table(redb::TableDefinition::<&[u8], &[u8]>::new("books"))
.expect("table survived");
assert_eq!(t.len().expect("len"), 2, "no rows may be lost");
assert_eq!(
t.get("k1".as_bytes()).expect("get").expect("k1").value(),
b"Dune"
);
}
#[test]
fn the_original_is_kept_as_a_backup_and_is_still_readable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.redb");
write_v2(&path, &[("k1", "Dune")]);
migrate_file_if_needed(&path).expect("migrate");
let backup = backup_path(&path);
assert!(backup.exists(), "a pre-migration copy must be kept");
let db = redb2::Database::open(&backup).expect("backup must still open as redb 2");
let rtx = db.begin_read().expect("read");
let t = rtx.open_table(TBL).expect("table");
assert_eq!(t.len().expect("len"), 1);
}
#[test]
fn a_current_database_is_left_completely_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("current.redb");
{
let db = redb::Database::create(&path).expect("create v4");
let wtx = db.begin_write().expect("txn");
{
let mut t = wtx
.open_table(redb::TableDefinition::<&[u8], &[u8]>::new("books"))
.expect("table");
t.insert("k".as_bytes(), "v".as_bytes()).expect("insert");
}
wtx.commit().expect("commit");
}
let before = std::fs::metadata(&path).unwrap().len();
assert!(!needs_migration(&path));
assert!(
!migrate_file_if_needed(&path).expect("no-op migration"),
"a current file must not be reported as migrated"
);
assert!(
!backup_path(&path).exists(),
"no backup should be written for a file that did not need migrating"
);
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
before,
"the file must not be rewritten"
);
}
#[test]
fn a_path_that_does_not_exist_yet_is_not_a_migration() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("brand-new.redb");
assert!(
!migrate_file_if_needed(&path).expect("a fresh path is fine"),
"creating a new database is not a migration"
);
assert!(!path.exists(), "detection must not create the file");
}
#[test]
fn no_partial_file_survives_a_successful_migration() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.redb");
write_v2(&path, &[("k1", "Dune")]);
migrate_file_if_needed(&path).expect("migrate");
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains("partial"))
.collect();
assert!(
leftovers.is_empty(),
"staging files left behind: {leftovers:?}"
);
}
#[test]
fn the_legacy_backend_message_names_the_way_out() {
assert!(is_legacy_format_error(
"storage error: Manual upgrade required. Expected file format version 3, \
but file is version 2"
));
assert!(!is_legacy_format_error("some unrelated io error"));
assert!(LEGACY_BACKEND_HELP.contains("exportSnapshot"));
assert!(LEGACY_BACKEND_HELP.contains("openWithSnapshot"));
}
}