Files
mnote/rust/crates/control-plane/src/migrations.rs
T

124 lines
3.5 KiB
Rust
Raw Normal View History

//! SQLite schema migrations for the MNote control-plane database.
//!
//! Each migration is a named step that runs inside a transaction.
//! The `migrations` table tracks which steps have been applied.
use rusqlite::{Connection, Result as SqlResult};
const MIGRATIONS: &[(&str, &str)] = &[
(
"v1-initial-schema",
include_str!("../migrations/001-initial-schema.sql"),
),
(
"v2-ai-runtime-store",
include_str!("../migrations/002-ai-runtime-store.sql"),
),
(
"v3-sidebar-shortcuts",
include_str!("../migrations/003-sidebar-shortcuts.sql"),
),
(
"v4-user-ui-preferences",
include_str!("../migrations/004-user-ui-preferences.sql"),
),
(
"v5-sidebar-shortcut-root-uri",
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
fn ensure_meta_table(conn: &Connection) -> SqlResult<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
}
/// Run all pending migrations inside a single transaction.
pub fn run_migrations(conn: &Connection) -> SqlResult<()> {
ensure_meta_table(conn)?;
let already_applied: Vec<String> = conn
.prepare("SELECT name FROM _migrations")?
.query_map([], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
for (name, sql) in MIGRATIONS {
if already_applied.contains(&name.to_string()) {
continue;
}
tracing::info!("Running migration: {name}");
conn.execute_batch(sql)?;
conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a fresh in-memory SQLite database and run migrations.
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
)
.expect("pragmas");
conn
}
fn table_exists(conn: &Connection, name: &str) -> bool {
conn.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
[name],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0
}
#[test]
fn migration_is_idempotent() {
let conn = fresh_db();
run_migrations(&conn).expect("first run");
run_migrations(&conn).expect("second run (idempotent)");
// All expected tables exist
for table in &[
"users",
"auth_identities",
"auth_sessions",
"workspaces",
"workspace_members",
"directory_grants",
"share_links",
"sync_state",
"ai_policies",
"audit_log",
"outbox_events",
"legacy_id_map",
"ai_runtime_runs",
"ai_runtime_events",
"sidebar_shortcuts",
"user_ui_preferences",
] {
assert!(table_exists(&conn, table), "table {table} should exist");
}
}
#[test]
fn migration_runs_fresh_db_ok() {
let conn = fresh_db();
run_migrations(&conn).expect("fresh migration ok");
assert!(table_exists(&conn, "users"));
assert!(table_exists(&conn, "_migrations"));
}
}