diff --git a/src/storage.rs b/src/storage.rs index dc03a29..288c8fd 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -68,7 +68,7 @@ pub struct PendingMaterialization { impl StateStore { pub fn open(paths: &AppPaths) -> anyhow::Result { const DATABASE_BUSY_TIMEOUT_MILLIS: u64 = 5_000; - let store = Self { + let mut store = Self { connection: Connection::open(&paths.database_path).with_context(|| { format!( "could not open Appa state at {}", @@ -260,8 +260,8 @@ impl StateStore { .map_err(Into::into) } - fn initialize_schema(&self) -> anyhow::Result<()> { - schema::initialize(&self.connection)?; + fn initialize_schema(&mut self) -> anyhow::Result<()> { + schema::initialize(&mut self.connection)?; Ok(()) } } diff --git a/src/storage/schema.rs b/src/storage/schema.rs index 60dd4dd..eeec171 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -1,52 +1,61 @@ use rusqlite::Connection; -const TABLE_STATEMENTS: &[&str] = &[ - "CREATE TABLE IF NOT EXISTS folders ( +const CURRENT_SCHEMA_VERSION: u32 = 1; + +struct Migration { + version: u32, + statements: &'static [&'static str], +} + +const MIGRATIONS: &[Migration] = &[Migration { + version: CURRENT_SCHEMA_VERSION, + 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 ( + "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 ( + "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 ( + "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 ( + "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 ( + "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 ( + "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 ( + "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 ( + "CREATE TABLE IF NOT EXISTS file_fingerprints ( folder_id TEXT NOT NULL, path TEXT NOT NULL, size_bytes INTEGER NOT NULL, @@ -55,16 +64,73 @@ const TABLE_STATEMENTS: &[&str] = &[ blob_hash TEXT NOT NULL, PRIMARY KEY (folder_id, path) );", - "CREATE TABLE IF NOT EXISTS pending_materializations ( + "CREATE TABLE IF NOT EXISTS pending_materializations ( folder_id TEXT PRIMARY KEY NOT NULL, entry TEXT NOT NULL, resulting_manifest TEXT NOT NULL );", -]; + ], +}]; + +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))?) +} -pub(super) fn initialize(connection: &Connection) -> rusqlite::Result<()> { - for statement in TABLE_STATEMENTS { - connection.execute_batch(statement)?; +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(()) + } +}