207 lines
8.4 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|