//! 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. //! //! 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. No-op if `state_db` already /// exists — legacy files are only ever consulted on the first boot after /// the db-consolidation upgrade. /// /// # 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). Retry-next-boot /// only holds for failures before `state_db` exists (the todos rename, or /// the reminders copy itself): once the copy has landed, `state_db` exists, /// so a `remove_file(legacy_reminders)` failure afterwards leaves an /// orphaned (already-copied, now-inert) legacy file behind rather than /// retrying — the rows are safe, but the leftover file won't self-clean. pub fn run(state_db: &Path, legacy_todos: &Path, legacy_reminders: &Path) -> Result<()> { if state_db.exists() { return Ok(()); } if 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 .execute_batch( "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;", ) .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 state_db_already_present_short_circuits() { 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); } #[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 ); } }