hyperhive/hive-agent/src/db_migrate.rs

207 lines
8.4 KiB
Rust

//! 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)
.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)
.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
);
}
}