diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index c3adcba..fde35aa 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,9 +1,12 @@ +use sqlx::migrate::Migrator; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use sqlx::{Pool, Sqlite}; use std::error::Error; use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; const DATABASE_FILE: &str = "friendolls.sqlite"; +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); pub struct AppDatabase { pool: Pool, @@ -35,14 +38,92 @@ pub async fn init(app: &AppHandle) -> Result<(), Box> { .connect_with(options) .await?; - sqlx::migrate!("./migrations").run(&pool).await?; + if database_requires_newer_app(&pool).await? { + show_outdated_app_dialog(app); + return std::future::pending().await; + } + + MIGRATOR.run(&pool).await?; app.manage(AppDatabase::new(pool)); Ok(()) } +async fn database_requires_newer_app(pool: &Pool) -> Result { + let has_migration_history = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", + ) + .fetch_one(pool) + .await?; + + if !has_migration_history { + return Ok(false); + } + + let latest_applied = + sqlx::query_scalar::<_, Option>("SELECT MAX(version) FROM _sqlx_migrations") + .fetch_one(pool) + .await?; + let latest_supported = MIGRATOR.iter().map(|migration| migration.version).max(); + + Ok(latest_applied + .zip(latest_supported) + .is_some_and(|(applied, supported)| applied > supported)) +} + +fn show_outdated_app_dialog(app: &AppHandle) { + app.dialog() + .message( + "Your on-device data was created by a newer version of Friendolls and cannot be opened safely by this version.\n\nDownload and install the latest version of Friendolls, then reopen the app. Your data has not been changed.", + ) + .title("Friendolls is out of date") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCustom("Close Friendolls".into())) + .show(move |_| std::process::exit(0)); +} + #[inline] pub fn command_error(error: impl std::fmt::Display) -> String { error.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn database_without_migration_history_does_not_require_newer_app() { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("connect to in-memory database"); + + assert!(!database_requires_newer_app(&pool).await.unwrap()); + } + + #[tokio::test] + async fn database_with_newer_migration_requires_newer_app() { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("connect to in-memory database"); + sqlx::query("CREATE TABLE _sqlx_migrations (version BIGINT PRIMARY KEY)") + .execute(&pool) + .await + .expect("create migration history"); + let newer_version = MIGRATOR + .iter() + .map(|migration| migration.version) + .max() + .expect("at least one embedded migration") + + 1; + sqlx::query("INSERT INTO _sqlx_migrations (version) VALUES (?)") + .bind(newer_version) + .execute(&pool) + .await + .expect("insert newer migration"); + + assert!(database_requires_newer_app(&pool).await.unwrap()); + } +}