use solo_core::{Result, TenantId};
use std::path::Path;
use super::{TENANTS_SUBDIR, TenantStatus, TenantsIndex};
use crate::key_material::KeyMaterial;
use crate::snapshot::{BAK_BASENAME, LIVE_BASENAME, TMP_BASENAME};
use solo_core::Error;
const HNSW_DATA_SUFFIX: &str = ".hnsw.data";
const HNSW_GRAPH_SUFFIX: &str = ".hnsw.graph";
pub fn migrate_v071_to_v080(data_dir: &Path, key: &KeyMaterial) -> Result<()> {
let tenants_dir = data_dir.join(TENANTS_SUBDIR);
std::fs::create_dir_all(&tenants_dir).map_err(|e| {
Error::storage(format!(
"create tenants subdir {}: {e}",
tenants_dir.display()
))
})?;
let mut index = TenantsIndex::open(data_dir, key)?;
let default_id = TenantId::default_tenant();
let default_db_filename = format!("{}.db", default_id.as_str());
let existing = index.lookup(&default_id)?;
let needs_move = match existing.as_ref().map(|r| r.status) {
None => {
index.register_with_status(
&default_id,
&default_db_filename,
Some("Default tenant (migrated from v0.7.1)"),
TenantStatus::PendingMigration,
)?;
true
}
Some(TenantStatus::PendingMigration) => true,
Some(TenantStatus::Active) => false,
Some(TenantStatus::PendingDelete) => {
return Err(Error::conflict(format!(
"default tenant is in pending_delete status; refusing to migrate \
on top of a half-deleted tenant. Operator action required."
)));
}
};
if needs_move {
rename_if_pending(
&data_dir.join("solo.db"),
&tenants_dir.join(&default_db_filename),
)?;
for suffix in &["-wal", "-shm"] {
let src = data_dir.join(format!("solo.db{suffix}"));
let dst = tenants_dir.join(format!("{default_db_filename}{suffix}"));
rename_if_pending(&src, &dst)?;
}
for basename in [LIVE_BASENAME, BAK_BASENAME, TMP_BASENAME] {
for suffix in [HNSW_DATA_SUFFIX, HNSW_GRAPH_SUFFIX] {
let filename = format!("{basename}{suffix}");
let src = data_dir.join(&filename);
let dst = tenants_dir.join(&filename);
rename_if_pending(&src, &dst)?;
}
}
index.set_status(&default_id, TenantStatus::Active)?;
}
Ok(())
}
fn rename_if_pending(src: &Path, dst: &Path) -> Result<()> {
let src_exists = src.exists();
let dst_exists = dst.exists();
match (src_exists, dst_exists) {
(false, _) => {
Ok(())
}
(true, true) => Err(Error::storage(format!(
"v0.7.1 → v0.8.0 migration: both source {} and destination {} exist; \
refusing to silently overwrite. Operator action required.",
src.display(),
dst.display()
))),
(true, false) => {
std::fs::rename(src, dst).map_err(|e| {
Error::storage(format!(
"rename {} → {}: {e}",
src.display(),
dst.display()
))
})?;
tracing::info!(
src = %src.display(),
dst = %dst.display(),
"v071→v080 mass-data-move: renamed file"
);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key_material::KeyMaterial;
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn test_key() -> KeyMaterial {
let salt = [0x42u8; 16];
KeyMaterial::derive("v071-migrate-test", &salt).expect("derive test key")
}
fn plant_v071_db(data_dir: &Path, key: &KeyMaterial) -> PathBuf {
let db_path = data_dir.join("solo.db");
let mut conn = crate::init::open_sqlcipher(&db_path, key).unwrap();
crate::migration::run_migrations(&mut conn).unwrap();
let now_ms: i64 = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT INTO episodes (
memory_id, ts_ms, source_type, content,
encoding_context_json, confidence, strength, salience,
tier, created_at_ms, updated_at_ms
) VALUES (?, ?, 'user_message', 'pre-migration episode',
'{}', 1.0, 0.5, 0.5, 'hot', ?, ?)",
rusqlite::params![
"00000000-0000-0000-0000-000000000001",
now_ms,
now_ms,
now_ms,
],
)
.unwrap();
drop(conn);
db_path
}
fn plant_hnsw_pair(data_dir: &Path, basename: &str) {
fs::write(
data_dir.join(format!("{basename}.hnsw.data")),
b"stub-data",
)
.unwrap();
fs::write(
data_dir.join(format!("{basename}.hnsw.graph")),
b"stub-graph",
)
.unwrap();
}
fn plant_wal_shm(data_dir: &Path) {
fs::write(data_dir.join("solo.db-wal"), b"stub-wal").unwrap();
fs::write(data_dir.join("solo.db-shm"), b"stub-shm").unwrap();
}
#[test]
fn migrate_from_fresh_install_creates_default_tenant() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
migrate_v071_to_v080(dir, &key).unwrap();
assert!(dir.join("tenants_index.db").exists());
assert!(dir.join("tenants").is_dir());
assert!(!dir.join("solo.db").exists());
let idx = TenantsIndex::open(dir, &key).unwrap();
let default = TenantId::default_tenant();
let rec = idx.lookup(&default).unwrap().unwrap();
assert_eq!(rec.tenant_id, default);
assert_eq!(rec.db_filename, "default.db");
assert_eq!(rec.status, TenantStatus::Active);
assert!(
rec.display_name
.as_deref()
.unwrap_or("")
.contains("v0.7.1"),
"display_name should mention v0.7.1 provenance; got {:?}",
rec.display_name
);
}
#[test]
fn migrate_from_v071_install_moves_db_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
plant_wal_shm(dir);
plant_hnsw_pair(dir, LIVE_BASENAME);
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
assert!(tenants.join("default.db").exists());
assert!(tenants.join("default.db-wal").exists());
assert!(tenants.join("default.db-shm").exists());
assert!(tenants.join(format!("{LIVE_BASENAME}.hnsw.data")).exists());
assert!(tenants.join(format!("{LIVE_BASENAME}.hnsw.graph")).exists());
for name in [
"solo.db",
"solo.db-wal",
"solo.db-shm",
"hnsw_episodes.hnsw.data",
"hnsw_episodes.hnsw.graph",
] {
assert!(
!dir.join(name).exists(),
"source must be moved: {}",
dir.join(name).display()
);
}
let moved_db = tenants.join("default.db");
let conn = crate::init::open_sqlcipher(&moved_db, &key).unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM episodes", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "pre-migration episode must survive the move");
}
#[test]
fn migrate_moves_bak_and_tmp_hnsw_pairs_if_present() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
plant_hnsw_pair(dir, LIVE_BASENAME);
plant_hnsw_pair(dir, BAK_BASENAME);
plant_hnsw_pair(dir, TMP_BASENAME);
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
for basename in [LIVE_BASENAME, BAK_BASENAME, TMP_BASENAME] {
for suffix in [".hnsw.data", ".hnsw.graph"] {
let expected = tenants.join(format!("{basename}{suffix}"));
assert!(expected.exists(), "missing moved: {}", expected.display());
let unexpected = dir.join(format!("{basename}{suffix}"));
assert!(
!unexpected.exists(),
"source remained: {}",
unexpected.display()
);
}
}
}
#[test]
fn migrate_idempotent() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
plant_wal_shm(dir);
migrate_v071_to_v080(dir, &key).unwrap();
let tenants_index_size_before =
fs::metadata(dir.join("tenants_index.db")).unwrap().len();
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
assert!(tenants.join("default.db").exists());
let idx = TenantsIndex::open(dir, &key).unwrap();
let listed = idx.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].status, TenantStatus::Active);
let tenants_index_size_after =
fs::metadata(dir.join("tenants_index.db")).unwrap().len();
assert_eq!(tenants_index_size_before, tenants_index_size_after);
}
#[test]
fn migrate_resumes_from_pending_migration() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
plant_wal_shm(dir);
fs::create_dir_all(dir.join("tenants")).unwrap();
{
let mut idx = TenantsIndex::open(dir, &key).unwrap();
let default_id = TenantId::default_tenant();
idx.register_with_status(
&default_id,
"default.db",
Some("Default tenant (migrated from v0.7.1)"),
TenantStatus::PendingMigration,
)
.unwrap();
}
assert!(dir.join("solo.db").exists());
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
assert!(tenants.join("default.db").exists());
assert!(tenants.join("default.db-wal").exists());
assert!(tenants.join("default.db-shm").exists());
assert!(!dir.join("solo.db").exists());
let idx = TenantsIndex::open(dir, &key).unwrap();
let rec = idx.lookup(&TenantId::default_tenant()).unwrap().unwrap();
assert_eq!(rec.status, TenantStatus::Active);
}
#[test]
fn migrate_resumes_from_partial_file_move() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
plant_wal_shm(dir);
fs::create_dir_all(dir.join("tenants")).unwrap();
fs::rename(dir.join("solo.db"), dir.join("tenants/default.db")).unwrap();
{
let mut idx = TenantsIndex::open(dir, &key).unwrap();
let default_id = TenantId::default_tenant();
idx.register_with_status(
&default_id,
"default.db",
None,
TenantStatus::PendingMigration,
)
.unwrap();
}
assert!(!dir.join("solo.db").exists());
assert!(dir.join("tenants/default.db").exists());
assert!(dir.join("solo.db-wal").exists());
assert!(dir.join("solo.db-shm").exists());
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
assert!(tenants.join("default.db").exists());
assert!(tenants.join("default.db-wal").exists());
assert!(tenants.join("default.db-shm").exists());
assert!(!dir.join("solo.db-wal").exists());
assert!(!dir.join("solo.db-shm").exists());
let idx = TenantsIndex::open(dir, &key).unwrap();
let rec = idx.lookup(&TenantId::default_tenant()).unwrap().unwrap();
assert_eq!(rec.status, TenantStatus::Active);
}
#[test]
fn migrate_when_already_active_is_noop() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
migrate_v071_to_v080(dir, &key).unwrap();
let tenants = dir.join("tenants");
let db_mtime_before =
fs::metadata(tenants.join("default.db")).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
migrate_v071_to_v080(dir, &key).unwrap();
let db_mtime_after =
fs::metadata(tenants.join("default.db")).unwrap().modified().unwrap();
assert_eq!(
db_mtime_before, db_mtime_after,
"no-op migration must not touch files"
);
let idx = TenantsIndex::open(dir, &key).unwrap();
let listed = idx.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].status, TenantStatus::Active);
}
#[test]
fn rename_if_pending_errors_on_both_exist() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src.bin");
let dst = tmp.path().join("dst.bin");
fs::write(&src, b"src").unwrap();
fs::write(&dst, b"dst").unwrap();
let err = rename_if_pending(&src, &dst).unwrap_err();
assert!(
err.to_string().contains("both source"),
"expected both-exist guard, got {err}"
);
assert_eq!(fs::read(&src).unwrap(), b"src");
assert_eq!(fs::read(&dst).unwrap(), b"dst");
}
#[test]
fn moved_sqlcipher_db_decrypts_under_same_key() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let key = test_key();
plant_v071_db(dir, &key);
migrate_v071_to_v080(dir, &key).unwrap();
let moved = dir.join("tenants/default.db");
let conn = crate::init::open_sqlcipher(&moved, &key).unwrap();
let _: u32 = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |r| r.get(0))
.unwrap();
drop(conn);
let _ = Connection::open(&moved); }
}