//! One-time on-boot migration from the pre-db-consolidation per-concern //! sqlite files (`hyperhive-todos.sqlite`, `hyperhive-reminders.sqlite`) //! into the single consolidated `hyperhive-state.sqlite` (mara: "not yet //! another sqlite ... add it to one of the existing ones"). Idempotent + best //! effort: `run` is called once in `main.rs`, before anything opens the //! consolidated db — a fresh harness boot with no legacy files (or one that //! already migrated) is a silent no-op, and a failed step leaves its legacy //! file in place to be retried on the next boot. //! //! Two legacy shapes fold in: //! - `hyperhive-todos.sqlite` already has exactly the `todos` table the //! consolidated db wants and nothing touches it during the rename window //! (this runs before the todo/reminder subsystems start), so a plain //! filesystem rename is correct and cheap — no sqlite involved. //! - `hyperhive-reminders.sqlite`'s `reminders` table then gets copied in via //! `ATTACH DATABASE` + `INSERT INTO ... SELECT`, preserving row ids (a //! reminder id an agent saw in a `get_loose_ends` call just before the //! migrating boot must still `cancel` correctly after it), and the legacy //! file is removed once the copy lands. use std::path::Path; use anyhow::{Context, Result}; use rusqlite::{Connection, params}; /// Fold the legacy `legacy_todos`/`legacy_reminders` files (if present) into /// the single consolidated `state_db` path. The legacy todos file is only /// renamed in while `state_db` does not exist yet; the legacy reminders file /// is copied in whenever it is still on disk, so a copy that failed on one /// boot is retried on the next. /// /// # Errors /// /// Propagates filesystem rename / sqlite attach-copy failures. Callers /// treat this as best-effort (log and continue) — `Todos`/`Reminders::open` /// against `state_db` still work standalone either way (todos would simply /// start empty rather than inheriting the legacy rows). A failed todos /// rename or reminders copy leaves its legacy file in place and is retried /// on the next boot, and so is a failed `remove_file(legacy_reminders)`. /// A retry skips rows already present with the same content, but a legacy /// row that was copied and then cancelled or pruned from the consolidated /// db comes back, which is the cost of retrying at all. pub fn run(state_db: &Path, legacy_todos: &Path, legacy_reminders: &Path) -> Result<()> { if !state_db.exists() && legacy_todos.exists() { std::fs::rename(legacy_todos, state_db).with_context(|| { format!( "rename legacy todos db {} -> {}", legacy_todos.display(), state_db.display() ) })?; } if legacy_reminders.exists() { // Ensure `state_db` exists and has the `reminders` schema — either // the rename above already brought a todos-only db across (no // `reminders` table yet), or there was no legacy todos file at all // and this creates `state_db` fresh. crate::reminders::Reminders::open(state_db) .context("apply reminders schema to consolidated db")?; migrate_reminders_rows(state_db, legacy_reminders) .context("copy legacy reminders rows into consolidated db")?; std::fs::remove_file(legacy_reminders).with_context(|| { format!("remove legacy reminders db {}", legacy_reminders.display()) })?; } Ok(()) } /// Copy every row of the legacy `reminders` table into `state_db`'s table /// of the same name, preserving `id`. Uses a scratch `ATTACH DATABASE` so /// the copy is one sqlite statement rather than a row-by-row read/insert /// loop. fn migrate_reminders_rows(state_db: &Path, legacy_reminders: &Path) -> Result<()> { let conn = Connection::open(state_db) .with_context(|| format!("open consolidated db {}", state_db.display()))?; conn.execute( "ATTACH DATABASE ?1 AS legacy", params![legacy_reminders.to_string_lossy()], ) .context("attach legacy reminders db")?; let copied = conn .query_row("SELECT COUNT(*) FROM main.reminders", [], |row| { row.get::<_, i64>(0) }) .context("count consolidated reminders") .and_then(|existing| { // Ids are kept while the table is empty (the first-boot case an // id seen just before the upgrade relies on). A retry after the // harness already stored reminders of its own cannot reuse them: // a colliding id would silently drop the legacy row. Those rows // are appended under fresh ids instead, skipping any that are // already there with the same content. let sql = if existing == 0 { "INSERT INTO reminders (id, message, file_path, due_at, created_at, sent_at) \ SELECT id, message, file_path, due_at, created_at, sent_at FROM legacy.reminders;" } else { "INSERT INTO reminders (message, file_path, due_at, created_at, sent_at) \ SELECT l.message, l.file_path, l.due_at, l.created_at, l.sent_at \ FROM legacy.reminders l WHERE NOT EXISTS ( \ SELECT 1 FROM main.reminders r WHERE r.message = l.message \ AND r.due_at = l.due_at AND r.created_at = l.created_at \ AND r.file_path IS l.file_path);" }; conn.execute_batch(sql) .context("copy legacy reminders rows") }); // DETACH regardless of the copy's outcome so the connection doesn't // leak the attachment on an early return via `?` inside this fn. let detach = conn.execute("DETACH DATABASE legacy", []); copied?; detach.context("detach legacy reminders db")?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{reminders::Reminders, todos::Todos}; #[test] fn no_legacy_files_is_a_noop() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join("hyperhive-state.sqlite"); let todos = dir.path().join("hyperhive-todos.sqlite"); let reminders = dir.path().join("hyperhive-reminders.sqlite"); run(&state, &todos, &reminders).unwrap(); assert!(!state.exists(), "nothing to migrate, nothing created"); } #[test] fn an_existing_state_db_leaves_a_legacy_todos_file_alone() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join("hyperhive-state.sqlite"); let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); std::fs::write(&legacy_todos, b"should be left alone").unwrap(); Todos::open(&state).unwrap(); run( &state, &legacy_todos, &dir.path().join("hyperhive-reminders.sqlite"), ) .unwrap(); assert_eq!( std::fs::read(&legacy_todos).unwrap(), b"should be left alone", "already-migrated boot must not touch a leftover legacy file" ); } #[test] fn renames_legacy_todos_into_state_db() { let dir = tempfile::tempdir().unwrap(); let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); let state = dir.path().join("hyperhive-state.sqlite"); { let store = Todos::open(&legacy_todos).unwrap(); store .upsert("matrix", Some("!a:x"), "1 unread", None, false) .unwrap(); } run( &state, &legacy_todos, &dir.path().join("hyperhive-reminders.sqlite"), ) .unwrap(); assert!(!legacy_todos.exists(), "renamed away"); let migrated = Todos::open(&state).unwrap(); let rows = migrated.list(None).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].summary, "1 unread"); } #[test] fn copies_legacy_reminders_preserving_ids() { let dir = tempfile::tempdir().unwrap(); let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); let state = dir.path().join("hyperhive-state.sqlite"); let orig_id = { let store = Reminders::open(&legacy_reminders).unwrap(); store.store("check on x", None, 1000).unwrap() }; run( &state, &dir.path().join("hyperhive-todos.sqlite"), &legacy_reminders, ) .unwrap(); assert!(!legacy_reminders.exists(), "removed after copy"); let migrated = Reminders::open(&state).unwrap(); let pending = migrated.list_pending().unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].id, orig_id, "id preserved across the copy"); assert_eq!(pending[0].due_at.timestamp(), 1000); } /// A copy that fails after `state_db` was created must be retried on the /// next boot, not abandoned because the consolidated db now exists. #[test] fn a_failed_reminders_copy_is_retried_once_the_state_db_exists() { let dir = tempfile::tempdir().unwrap(); let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); let state = dir.path().join("hyperhive-state.sqlite"); let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); std::fs::write(&legacy_reminders, b"not a sqlite file").unwrap(); run(&state, &legacy_todos, &legacy_reminders).unwrap_err(); assert!(state.exists(), "the failed copy still created the db"); assert!(legacy_reminders.exists(), "legacy file kept for a retry"); std::fs::remove_file(&legacy_reminders).unwrap(); Reminders::open(&legacy_reminders) .unwrap() .store("ping", None, 500) .unwrap(); run(&state, &legacy_todos, &legacy_reminders).unwrap(); assert!(!legacy_reminders.exists(), "removed after the retry"); assert_eq!( Reminders::open(&state) .unwrap() .list_pending() .unwrap() .len(), 1 ); } /// The harness boots after a failed copy and stores reminders of its own, /// so their ids overlap the legacy ones. The retry must keep both sets. #[test] fn a_retry_keeps_legacy_reminders_whose_ids_collide_with_new_ones() { let dir = tempfile::tempdir().unwrap(); let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); let state = dir.path().join("hyperhive-state.sqlite"); let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); std::fs::write(&legacy_reminders, b"not a sqlite file").unwrap(); run(&state, &legacy_todos, &legacy_reminders).unwrap_err(); Reminders::open(&state) .unwrap() .store("new while degraded", None, 900) .unwrap(); std::fs::remove_file(&legacy_reminders).unwrap(); { let legacy = Reminders::open(&legacy_reminders).unwrap(); legacy.store("legacy one", None, 100).unwrap(); legacy.store("legacy two", None, 200).unwrap(); } run(&state, &legacy_todos, &legacy_reminders).unwrap(); let messages: Vec = Reminders::open(&state) .unwrap() .list_pending() .unwrap() .into_iter() .map(|r| r.message) .collect(); assert_eq!(messages.len(), 3, "got {messages:?}"); for expected in ["new while degraded", "legacy one", "legacy two"] { assert!(messages.iter().any(|m| m == expected), "missing {expected}"); } } #[test] fn folds_both_legacy_files_into_one_consolidated_db() { let dir = tempfile::tempdir().unwrap(); let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); let state = dir.path().join("hyperhive-state.sqlite"); { Todos::open(&legacy_todos) .unwrap() .upsert("bash", None, "task done", None, false) .unwrap(); Reminders::open(&legacy_reminders) .unwrap() .store("ping", None, 500) .unwrap(); } run(&state, &legacy_todos, &legacy_reminders).unwrap(); assert!(!legacy_todos.exists()); assert!(!legacy_reminders.exists()); assert_eq!(Todos::open(&state).unwrap().list(None).unwrap().len(), 1); assert_eq!( Reminders::open(&state) .unwrap() .list_pending() .unwrap() .len(), 1 ); } }