use rusqlite::Connection; const CURRENT_SCHEMA_VERSION: u32 = 3; struct Migration { version: u32, statements: &'static [&'static str], } const MIGRATIONS: &[Migration] = &[ Migration { version: 1, statements: &[ "CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, capability TEXT NOT NULL, mode TEXT NOT NULL DEFAULT '\"send_receive\"' );", "CREATE TABLE IF NOT EXISTS known_peers ( folder_id TEXT NOT NULL, device_id TEXT NOT NULL, endpoint TEXT NOT NULL, last_seen TEXT NOT NULL, PRIMARY KEY (folder_id, device_id) );", "CREATE TABLE IF NOT EXISTS join_invites ( folder_id TEXT PRIMARY KEY NOT NULL, invite TEXT NOT NULL );", "CREATE TABLE IF NOT EXISTS folder_rosters ( folder_id TEXT PRIMARY KEY NOT NULL, epoch INTEGER NOT NULL, roster TEXT NOT NULL );", "CREATE TABLE IF NOT EXISTS manifests ( folder_id TEXT PRIMARY KEY NOT NULL, manifest TEXT NOT NULL );", "CREATE TABLE IF NOT EXISTS manifest_history_compact ( revision INTEGER PRIMARY KEY AUTOINCREMENT, folder_id TEXT NOT NULL, saved_at TEXT NOT NULL, kind TEXT NOT NULL, payload BLOB NOT NULL );", "CREATE TABLE IF NOT EXISTS counters ( folder_id TEXT NOT NULL, device_id TEXT NOT NULL, counter INTEGER NOT NULL, PRIMARY KEY (folder_id, device_id) );", "CREATE TABLE IF NOT EXISTS sync_state ( folder_id TEXT PRIMARY KEY NOT NULL, last_successful_sync TEXT, last_error TEXT );", "CREATE TABLE IF NOT EXISTS file_fingerprints ( folder_id TEXT NOT NULL, path TEXT NOT NULL, size_bytes INTEGER NOT NULL, modified_at_nanos INTEGER NOT NULL, file_id INTEGER, blob_hash TEXT NOT NULL, PRIMARY KEY (folder_id, path) );", "CREATE TABLE IF NOT EXISTS pending_materializations ( folder_id TEXT PRIMARY KEY NOT NULL, entry TEXT NOT NULL, resulting_manifest TEXT NOT NULL );", ], }, Migration { version: 2, statements: &[ "CREATE TABLE IF NOT EXISTS audit_events ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_hash TEXT NOT NULL UNIQUE, parent_hash TEXT, event TEXT NOT NULL, PRIMARY KEY (folder_id, author_device_id, sequence) );", "CREATE TABLE IF NOT EXISTS audit_heads ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_hash TEXT NOT NULL, PRIMARY KEY (folder_id, author_device_id) );", "CREATE TABLE IF NOT EXISTS audit_integrity_faults ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, detected_at TEXT NOT NULL, detail TEXT NOT NULL );", ], }, Migration { version: 3, statements: &[ "CREATE TABLE IF NOT EXISTS legacy_audit_events ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_hash TEXT NOT NULL, parent_hash TEXT, event TEXT NOT NULL, archived_at TEXT NOT NULL );", "INSERT INTO legacy_audit_events (folder_id, author_device_id, sequence, event_hash, parent_hash, event, archived_at) SELECT folder_id, author_device_id, sequence, event_hash, parent_hash, event, datetime('now') FROM audit_events;", "INSERT INTO audit_integrity_faults (folder_id, author_device_id, detected_at, detail) SELECT folder_id, author_device_id, datetime('now'), 'audit protocol v1 events were archived because they lack signed historical roster snapshots' FROM audit_events;", "DELETE FROM audit_heads;", "DELETE FROM audit_events;", ], }, ]; pub(super) fn initialize(connection: &mut Connection) -> anyhow::Result<()> { let installed_version = schema_version(connection)?; if installed_version > CURRENT_SCHEMA_VERSION { anyhow::bail!( "Appa state schema version {installed_version} is newer than this Appa version supports" ); } for migration in MIGRATIONS .iter() .filter(|migration| migration.version > installed_version) { apply_migration(connection, migration)?; } Ok(()) } fn schema_version(connection: &Connection) -> anyhow::Result { Ok(connection.query_row("PRAGMA user_version", [], |row| row.get(0))?) } fn apply_migration(connection: &mut Connection, migration: &Migration) -> anyhow::Result<()> { let transaction = connection.transaction()?; for statement in migration.statements { transaction.execute_batch(statement)?; } transaction.pragma_update(None, "user_version", migration.version)?; transaction.commit()?; Ok(()) } #[cfg(test)] mod tests { use rusqlite::Connection; use super::{CURRENT_SCHEMA_VERSION, initialize, schema_version}; #[test] fn initializes_the_current_schema_version() -> anyhow::Result<()> { let mut connection = Connection::open_in_memory()?; initialize(&mut connection)?; assert_eq!(schema_version(&connection)?, CURRENT_SCHEMA_VERSION); Ok(()) } #[test] fn rejects_a_database_from_a_newer_app_version() -> anyhow::Result<()> { let mut connection = Connection::open_in_memory()?; connection.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION + 1)?; let error = initialize(&mut connection).expect_err("newer schemas must be rejected"); assert!( error .to_string() .contains("newer than this Appa version supports") ); Ok(()) } }