//! Forward-only schema migration mechanism. //! //! Approach: each migration is a `&'static str` SQL script applied in //! order. The current schema version is tracked in SQLite's //! `PRAGMA user_version`. Running the migrator is idempotent — already- //! applied versions are skipped. //! //! Why not an external migration framework? At this stage we want the //! mechanism in the audit surface to be explainable in ~50 lines and //! to have no transitive crate dependencies. Production code may //! promote this to `refinery`, `sqlx::migrate!`, or similar if the //! complexity warrants. use rusqlite::{Connection, TransactionBehavior}; use thiserror::Error; use tracing::info; /// A single migration step. `version` is monotonically increasing /// starting at 1. `sql` is applied inside a transaction. #[derive(Debug, Clone, Copy)] pub struct Migration { pub version: u32, pub name: &'static str, pub sql: &'static str, } #[derive(Debug, Error)] pub enum MigrationError { #[error("sqlite error during migration: {0}")] Sqlite(#[from] rusqlite::Error), #[error("migrations must be ordered by ascending version; got {got} after {prev}")] OutOfOrder { prev: u32, got: u32 }, #[error("database is at version {db}, which is newer than the highest known migration {max}")] FromTheFuture { db: u32, max: u32 }, } /// Forward-only migrator over a fixed list of `Migration` steps. pub struct Migrator { migrations: Vec, } impl Migrator { pub fn new(migrations: Vec) -> Result { let mut prev = 0u32; for m in &migrations { if m.version <= prev { return Err(MigrationError::OutOfOrder { prev, got: m.version }); } prev = m.version; } Ok(Self { migrations }) } pub fn highest_version(&self) -> u32 { self.migrations.last().map(|m| m.version).unwrap_or(0) } /// Apply every pending migration to `conn`. Idempotent: if the DB /// is already at the highest version, this is a no-op. Each /// migration is wrapped in an IMMEDIATE transaction; on failure /// the transaction is rolled back and the DB version is unchanged. pub fn run(&self, conn: &mut Connection) -> Result { let current: u32 = conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32; if current > self.highest_version() { return Err(MigrationError::FromTheFuture { db: current, max: self.highest_version(), }); } let pending: Vec<&Migration> = self .migrations .iter() .filter(|m| m.version > current) .collect(); if pending.is_empty() { info!(target: "spike", "no migrations to apply (already at v{current})"); return Ok(current); } for m in pending { info!(target: "spike", "applying migration v{} {:?}", m.version, m.name); let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; tx.execute_batch(m.sql)?; // PRAGMA user_version cannot be parameterised; the version // is from our own static migration list and is therefore // safe to interpolate. tx.execute_batch(&format!("PRAGMA user_version = {}", m.version))?; tx.commit()?; } let final_v: u32 = conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32; Ok(final_v) } } /// Canonical Chanora PoC migration list. /// /// Each version is intentionally small and self-contained. Migrations /// in the production crate will live in `migrations/` as separate /// files; the PoC keeps them inline for visibility. pub fn canonical_migrations() -> Vec { vec![ Migration { version: 1, name: "initial bookmarks + settings", sql: r#" CREATE TABLE bookmarks ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_name TEXT NOT NULL, server_host TEXT NOT NULL, identity_ref TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); CREATE UNIQUE INDEX ix_bookmarks_host_identity ON bookmarks(server_host, identity_ref); CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); "#, }, Migration { version: 2, name: "add bookmarks.nickname", sql: r#" ALTER TABLE bookmarks ADD COLUMN nickname TEXT; "#, }, Migration { version: 3, name: "add bookmarks.last_connected_at", sql: r#" ALTER TABLE bookmarks ADD COLUMN last_connected_at INTEGER; CREATE INDEX ix_bookmarks_last_connected ON bookmarks(last_connected_at DESC); "#, }, ] }