234 lines
7.1 KiB
Rust
234 lines
7.1 KiB
Rust
//! 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<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(())
|
|
}
|
|
|
|
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::<String>(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::<i64>(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");
|
|
}
|
|
}
|
|
}
|