//! 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 crate::error::ControlPlaneError; 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"), ), ( "v6-navigation-recent", include_str!("../migrations/006-navigation-recent.sql"), ), ( "v7-ai-agent-profile-policy", include_str!("../migrations/007-ai-agent-profile-policy.sql"), ), ( "v8-ai-external-conversation-bindings", include_str!("../migrations/008-ai-external-conversation-bindings.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 = 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(()) } pub async fn run_libsql_migrations(conn: &libsql::Connection) -> Result<(), ControlPlaneError> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS _migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) );", ) .await?; let mut rows = conn.query("SELECT name FROM _migrations", ()).await?; let mut already_applied = Vec::new(); while let Some(row) = rows.next().await? { already_applied.push(row.get::(0)?); } for (name, sql) in MIGRATIONS { if already_applied.iter().any(|value| value == name) { continue; } tracing::info!("Running libSQL migration: {name}"); conn.execute_batch(sql).await?; conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [*name]) .await?; } 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 } #[cfg(feature = "turso-unit-tests")] async fn libsql_table_exists(conn: &libsql::Connection, name: &str) -> bool { let mut rows = conn .query( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1", [name], ) .await .unwrap_or_else(|_| panic!("query libSQL table {name}")); if let Some(row) = rows.next().await.unwrap_or(None) { row.get::(0).unwrap_or(0) > 0 } else { false } } #[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", "user_navigation_recent", "ai_agent_profiles", "ai_agent_profile_grants", "ai_external_conversation_bindings", ] { 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")); } #[cfg(feature = "turso-unit-tests")] #[test] fn libsql_migration_is_idempotent() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("build tokio runtime"); let database = rt .block_on(async { libsql::Builder::new_local(":memory:").build().await }) .expect("build libSQL in-memory database"); let conn = database.connect().expect("connect libSQL"); rt.block_on(async { run_libsql_migrations(&conn) .await .expect("first libSQL migration run"); run_libsql_migrations(&conn) .await .expect("second libSQL migration run (idempotent)"); }); let tables: &[&str] = &[ "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", "user_navigation_recent", "ai_agent_profiles", "ai_agent_profile_grants", "ai_external_conversation_bindings", ]; for table in tables { let exists = rt.block_on(async { libsql_table_exists(&conn, table).await }); assert!(exists, "libSQL table {table} should exist"); } } }