use crate::state::DirectoryInfo; use sha2::{Digest, Sha384}; use sqlx::migrate::{Migration, Migrator}; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, }; use sqlx::{ConnectOptions, Connection, Pool, Sqlite}; use std::path::{Path, PathBuf}; use std::time::Duration; static MIGRATOR: Migrator = sqlx::migrate!(); const UPDATE_CHANNEL_STATE_FILE: &str = "update-channel.json"; const LEGACY_APP_DB_FILE: &str = "app.db"; const INITIAL_MIGRATION_VERSION: i64 = 20240711194701; const COLLIDING_JAVA_DISCOVERY_MIGRATION_VERSION: i64 = 20260722120000; const JAVA_DISCOVERY_MIGRATION_VERSION: i64 = 20260722120001; const TEMPORARY_JAVA_DISCOVERY_MIGRATION_VERSION: i64 = 20260723121000; const COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION: i64 = 20260725170000; const HOME_DASHBOARD_MIGRATION_VERSION: i64 = 20260727110000; const PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION: i64 = 20260728100000; const OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION: i64 = 20260802121000; #[cfg(test)] const RECONCILE_PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION: i64 = 20260728110000; #[cfg(test)] const JAVA_DEFAULT_VERSIONS_MIGRATION_VERSION: i64 = 20260729110000; #[cfg(test)] const SYSTEM_PROXY_SETTING_MIGRATION_VERSION: i64 = 20260802122000; const INSTANCE_CONTENT_OWNERSHIP_MIGRATION_VERSION: i64 = 20260803120000; #[cfg(test)] const CONTENT_DEPENDENCY_EDGES_MIGRATION_VERSION: i64 = 20260815000000; #[cfg(test)] const CONTENT_DEPENDENCY_LOCAL_PROVIDER_MIGRATION_VERSION: i64 = 20260815000001; #[cfg(test)] const CONTENT_DEPENDENCY_BACKFILL_MARKER_MIGRATION_VERSION: i64 = 20260815000002; #[cfg(test)] const CONTENT_DEPENDENCY_ENDPOINT_PROVIDERS_MIGRATION_VERSION: i64 = 20260817130000; #[cfg(test)] const CURSEFORGE_DOWNLOAD_RESTRICTION_BYPASS_MIGRATION_VERSION: i64 = 20260817120000; const AI_PROVIDER_MIGRATION_VERSION: i64 = 20260805120000; const PROXY_CONFIG_MIGRATION_VERSION: i64 = 20260820120000; const CONTENT_FAVORITES_MIGRATION_VERSION: i64 = 20260820200000; // This migration was changed by the launcher rebrand after it had already // shipped. Keep the checksums of the original LF and CRLF variants so existing // installations can move to the current canonical migration without losing // their database. const LEGACY_INITIAL_MIGRATION_CHECKSUMS: &[&str] = &[ "49364b3e1b0d0169579ed93eb1f8e215216b84300a816891d0d922d3e03c69101e17e2bbe91ac1f54234c77cbd6b8bc3", "d95bfef1c3b2b530d2efd810202c85f93a9342ab40497b15653eea9b129806333cf610eebcecfa91accaa53a14bfc5df", ]; const COLLIDING_JAVA_DISCOVERY_MIGRATION_CHECKSUMS: &[&str] = &[ "986c9afb410ad7086617c3707611c3b9a46be69bc33e2a0bd1b32611266301f536e28137a47b11337622a953c29ad595", "bfb8686214294786f8e81ea05f06bb08deeb4183da3d1230ebf379bc2ba9f5c5521f3590306bea668c292e03c3aacd85", ]; const TEMPORARY_JAVA_DISCOVERY_MIGRATION_CHECKSUMS: &[&str] = &[ "35cbd4e0a4528bee302f06000e0971aad2f575488ebb5c04ec6849e15efc6f3f996395c11f9cc431c62ba4d9e3a41cc3", "7b99e048d7eb88cbfdd913cc3d799c6acabd58142204cde336da593fa3ca6d4f44336fd5d42a5822ab1e0b485352eb9b", ]; const COLLIDING_HOME_DASHBOARD_MIGRATION_CHECKSUMS: &[&str] = &[ "d75220a25e54880d29ba179c0e7ca04e975b8ac9cc35c1e3569300b15570a0cd7239b353a2fe5733105b76a56215fda2", "f2806d4e16a055545dc8b90ced7896c85c1c26a5c8ec00fd44fe86fee6e4fe8c2413dfa20b16d1a8e227a0b55fa1e766", ]; const LEGACY_PROVIDER_QUALIFIED_CONTENT_MIGRATION_CHECKSUMS: &[&str] = &[ "58ca9f7fe905f3d51ce68e03422f0f60035f4d4278738d2432869528d8a5951b4c053090a7e605e32797206b5e744010", ]; const LEGACY_OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_CHECKSUMS: &[&str] = &[ "2fe6c8d9276c35e9400ab2e56ee1f30209db192c4edffc1dc6987bd79cbc04a101d0b2988dac1ba34d77df2acf869a5e", ]; const LEGACY_AI_PROVIDER_MIGRATION_CHECKSUMS: &[&str] = &[ "eb25e9694a8a2e1787a399635a17c90b9785cc5599d79e31a72c15017422efaeb36cd0b6ec349368128097da7ecaafc1", ]; pub(crate) async fn connect( app_identifier: &str, ) -> crate::Result> { let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; crate::util::io::create_dir_all(&settings_dir).await?; migrate_legacy_release_database(&settings_dir).await?; let db_path = app_db_path(&settings_dir).await?; let db_dir = db_path.parent().ok_or_else(|| { crate::ErrorKind::FSError(format!( "App database path {} has no parent directory", db_path.display() )) })?; crate::util::io::create_dir_all(db_dir).await?; connect_app_db(&db_path).await } pub async fn copy_release_database_to_beta( app_identifier: &str, ) -> crate::Result<()> { let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; let release_path = settings_dir.join("release").join(LEGACY_APP_DB_FILE); if !release_path.try_exists()? { return Err(crate::ErrorKind::FSError( "The Release database does not exist".to_string(), ) .into()); } let beta_dir = settings_dir.join("beta"); crate::util::io::create_dir_all(&beta_dir).await?; let beta_path = beta_dir.join(LEGACY_APP_DB_FILE); if beta_path.try_exists()? { return Err(crate::ErrorKind::FSError( "The Beta database already exists".to_string(), ) .into()); } copy_app_database(&release_path, &beta_path).await?; tracing::info!( source = %release_path.display(), destination = %beta_path.display(), "Copied the Release database into the Beta update channel" ); Ok(()) } pub async fn beta_database_exists(app_identifier: &str) -> crate::Result { let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; Ok(settings_dir .join("beta") .join(LEGACY_APP_DB_FILE) .try_exists()?) } pub async fn current_app_database_path( app_identifier: &str, ) -> crate::Result { let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; app_db_path(&settings_dir).await } pub async fn copy_database_between_channels( app_identifier: &str, source_channel: &str, target_channel: &str, ) -> crate::Result<()> { if !matches!(source_channel, "release" | "beta") || !matches!(target_channel, "release" | "beta") || source_channel == target_channel { return Err(crate::ErrorKind::InputError( "Database channels must be different Release or Beta channels" .to_string(), ) .into()); } let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; let active_channel = read_update_channel(&settings_dir).await?; if target_channel == active_channel { return Err(crate::ErrorKind::InputError( "The active database cannot be overwritten while Axolotl is running".to_string(), ) .into()); } let source_path = settings_dir.join(source_channel).join(LEGACY_APP_DB_FILE); let target_dir = settings_dir.join(target_channel); let target_path = target_dir.join(LEGACY_APP_DB_FILE); if !source_path.try_exists()? { return Err(crate::ErrorKind::FSError(format!( "The {source_channel} database does not exist" )) .into()); } crate::util::io::create_dir_all(&target_dir).await?; copy_app_database(&source_path, &target_path).await?; tracing::info!( source = %source_path.display(), destination = %target_path.display(), "Overwrote the inactive update channel database" ); Ok(()) } async fn copy_app_database(source: &Path, target: &Path) -> crate::Result<()> { let temporary = sidecar_path(target, ".copying"); remove_database_files(&temporary).await?; let source_pool = open_app_db_pool(source).await?; let escaped_path = temporary.to_string_lossy().replace('\'', "''"); let snapshot_result = sqlx::query(&format!("VACUUM INTO '{escaped_path}'")) .execute(&source_pool) .await; source_pool.close().await; if let Err(error) = snapshot_result { let _ = remove_database_files(&temporary).await; return Err(error.into()); } if let Err(error) = verify_database_file(&temporary).await { let _ = remove_database_files(&temporary).await; return Err(error); } let previous = sidecar_path(target, ".replacing"); remove_database_files(&previous).await?; let had_target = target.try_exists()?; if had_target { super::db_backup::archive_database_files(target, &previous).await?; } else { super::db_backup::remove_database_sidecars(target).await?; } if let Err(error) = tokio::fs::rename(&temporary, target).await { if had_target { super::db_backup::restore_archived_database_files( &previous, target, ) .await?; } let _ = remove_database_files(&temporary).await; return Err(error.into()); } if let Err(error) = remove_database_files(&previous).await { tracing::warn!( database = %previous.display(), error = %error, "Copied channel database but could not remove the previous snapshot" ); } Ok(()) } async fn verify_database_file(path: &Path) -> crate::Result<()> { let options = SqliteConnectOptions::new() .filename(path) .busy_timeout(Duration::from_secs(30)) .read_only(true) .create_if_missing(false); let mut connection = options.connect().await?; let rows = sqlx::query_scalar::<_, String>("PRAGMA quick_check(1)") .fetch_all(&mut connection) .await?; connection.close().await?; if rows.as_slice() != ["ok"] { return Err(crate::ErrorKind::FSError(format!( "Database snapshot {} failed its integrity check: {}", path.display(), rows.join("; ") )) .into()); } Ok(()) } async fn remove_database_files(database: &Path) -> crate::Result<()> { match tokio::fs::remove_file(database).await { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } super::db_backup::remove_database_sidecars(database).await } pub async fn backup_current_app_db_for_update( app_identifier: &str, target_version: &str, ) -> crate::Result { let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier) .ok_or(crate::ErrorKind::FSError( "Could not find valid config dir".to_string(), ))?; let db_path = app_db_path(&settings_dir).await?; super::db_backup::backup_app_db_for_update(&db_path, target_version).await } async fn app_db_path(settings_dir: &Path) -> crate::Result { let channel = read_update_channel(settings_dir).await?; Ok(settings_dir.join(channel).join(LEGACY_APP_DB_FILE)) } /// The update channel a fresh install should default to. Beta builds carry a /// pre-release version (e.g. `1.0.2-beta7`), so their installers should start /// on the Beta channel instead of silently falling back to Release. pub fn default_update_channel() -> &'static str { let version = env!("CARGO_PKG_VERSION"); if version.contains('-') { "beta" } else { "release" } } async fn read_update_channel( settings_dir: &Path, ) -> crate::Result<&'static str> { let path = settings_dir.join(UPDATE_CHANNEL_STATE_FILE); let contents = match tokio::fs::read_to_string(&path).await { Ok(contents) => contents, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(default_update_channel()); } Err(error) => return Err(error.into()), }; let channel = serde_json::from_str::(&contents) .ok() .and_then(|value| { value .get("active_channel") .and_then(serde_json::Value::as_str) .map(str::to_owned) }); match channel.as_deref() { Some("beta") => Ok("beta"), Some("release") | None => Ok("release"), Some(other) => Err(crate::ErrorKind::FSError(format!( "Invalid update channel {other:?} in {}", path.display() )) .into()), } } async fn migrate_legacy_release_database( settings_dir: &Path, ) -> crate::Result<()> { let legacy_path = settings_dir.join(LEGACY_APP_DB_FILE); if !legacy_path.try_exists()? { return Ok(()); } let release_dir = settings_dir.join("release"); let release_path = release_dir.join(LEGACY_APP_DB_FILE); if release_path.try_exists()? { return Ok(()); } crate::util::io::create_dir_all(&release_dir).await?; tokio::fs::rename(&legacy_path, &release_path).await?; for suffix in ["-wal", "-shm"] { let legacy_sidecar = sidecar_path(&legacy_path, suffix); if legacy_sidecar.try_exists()? { tokio::fs::rename( &legacy_sidecar, sidecar_path(&release_path, suffix), ) .await?; } } tracing::info!( database = %release_path.display(), "Migrated the legacy app database into the Release update channel" ); Ok(()) } fn sidecar_path(path: &Path, suffix: &str) -> PathBuf { let mut path = path.as_os_str().to_owned(); path.push(suffix); PathBuf::from(path) } async fn connect_app_db(db_path: &Path) -> crate::Result> { super::db_backup::restore_corrupt_app_db_if_needed(db_path).await?; super::db_backup::maybe_backup_existing_app_db(db_path).await?; open_migrated_app_db(db_path).await } async fn open_migrated_app_db(db_path: &Path) -> crate::Result> { let pool = open_app_db_pool(db_path).await?; if let Err(err) = stale_data_cleanup(&pool).await { tracing::warn!( "Failed to clean up stale data from state database before migrations: {err}" ); } reconcile_existing_home_dashboard_migration(&pool).await?; reconcile_legacy_provider_qualified_content_migration(&pool).await?; reconcile_legacy_official_preferred_download_source_migration(&pool) .await?; reconcile_legacy_ai_provider_migration(&pool).await?; reconcile_compatible_migration_checksums(&pool).await?; reconcile_existing_java_discovery_migration(&pool).await?; reconcile_existing_proxy_config_migration(&pool).await?; reconcile_existing_content_favorites_migration(&pool).await?; reconcile_pending_instance_content_ownership_duplicates(&pool).await?; MIGRATOR.run(&pool).await?; record_current_app_version(&pool).await?; if let Err(err) = stale_data_cleanup(&pool).await { tracing::warn!( "Failed to clean up stale data from state database: {err}" ); } Ok(pool) } async fn reconcile_pending_instance_content_ownership_duplicates( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations' )", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let migration_applied: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM _sqlx_migrations WHERE version = ? AND success = TRUE )", ) .bind(INSTANCE_CONTENT_OWNERSHIP_MIGRATION_VERSION) .fetch_one(pool) .await?; if migration_applied { return Ok(()); } let legacy_schema_matches: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('instance_content_entries') WHERE name = 'source_kind') AND NOT EXISTS(SELECT 1 FROM pragma_table_info('instance_content_entries') WHERE name = 'ownership_kind') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_files') WHERE name = 'relative_path') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_links') WHERE name = 'link_kind') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'is_origin') AND NOT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_pack_members' )", ) .fetch_one(pool) .await?; if !legacy_schema_matches { return Ok(()); } let result = sqlx::query( r#" WITH migration_candidates AS ( SELECT entry.id, entry.content_set_id, CASE WHEN origin.provider IS NOT NULL THEN origin.provider || ':' || origin.provider_project_id || ':' || entry.project_type ELSE 'path:' || lower(replace(file.relative_path, '\', '/')) END AS member_key, file.missing, entry.enabled AS entry_enabled, file.enabled AS file_enabled, entry.modified_at AS entry_modified_at, file.modified_at AS file_modified_at FROM instance_content_entries entry INNER JOIN instance_files file ON file.id = entry.file_id INNER JOIN instance_links link ON link.instance_id = entry.instance_id LEFT JOIN instance_content_provider_refs origin ON origin.content_entry_id = entry.id AND origin.is_origin = 1 WHERE (link.link_kind IN ('modrinth_modpack', 'server_project_modpack') AND entry.source_kind = 'modrinth_modpack') OR (link.link_kind = 'curseforge_modpack' AND entry.source_kind = 'curseforge') OR (link.link_kind = 'imported_modpack' AND entry.source_kind IN ( 'imported_modpack', 'modrinth_modpack', 'curseforge' )) ), ranked_candidates AS ( SELECT id, row_number() OVER ( PARTITION BY content_set_id, member_key ORDER BY CASE WHEN missing = 0 THEN 0 ELSE 1 END, CASE WHEN entry_enabled = 1 AND file_enabled = 1 THEN 0 ELSE 1 END, entry_modified_at DESC, file_modified_at DESC, id ) AS member_rank FROM migration_candidates ) UPDATE instance_content_entries SET source_kind = 'local' WHERE id IN ( SELECT id FROM ranked_candidates WHERE member_rank > 1 ) "#, ) .execute(pool) .await?; if result.rows_affected() > 0 { tracing::warn!( version = INSTANCE_CONTENT_OWNERSHIP_MIGRATION_VERSION, reclassified_entries = result.rows_affected(), "Reclassified duplicate legacy pack members before migration" ); } Ok(()) } /// Reconciles historical migration checksums that differ only because of line /// endings, plus the known pre-rebrand form of the initial migration. /// /// SQLx hashes the raw migration bytes, so an otherwise identical migration /// built with LF, CRLF, or mixed line endings receives a different checksum. /// Unknown checksums are deliberately left untouched for SQLx to reject. async fn reconcile_compatible_migration_checksums( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let applied_migrations: Vec<(i64, Vec)> = sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations") .fetch_all(pool) .await?; for (version, applied_checksum) in applied_migrations { let Some(migration) = MIGRATOR .iter() .find(|migration| migration.version == version) else { continue; }; let current_checksum: &[u8] = migration.checksum.as_ref(); if applied_checksum.as_slice() == current_checksum { continue; } if version == COLLIDING_JAVA_DISCOVERY_MIGRATION_VERSION && COLLIDING_JAVA_DISCOVERY_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(&applied_checksum).as_str()) { reconcile_colliding_java_discovery_migration(pool).await?; update_migration_checksum(pool, version, current_checksum).await?; tracing::warn!( version, "Reconciled colliding Java discovery migration version" ); continue; } if !is_compatible_migration_checksum( version, &applied_checksum, migration, ) { continue; } update_migration_checksum(pool, version, current_checksum).await?; tracing::warn!( version, "Reconciled a compatible historical migration checksum" ); } Ok(()) } async fn update_migration_checksum( pool: &Pool, version: i64, checksum: &[u8], ) -> crate::Result<()> { sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") .bind(checksum) .bind(version) .execute(pool) .await?; Ok(()) } async fn reconcile_legacy_ai_provider_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let applied_checksum: Option> = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ? AND success = TRUE", ) .bind(AI_PROVIDER_MIGRATION_VERSION) .fetch_optional(pool) .await?; let Some(applied_checksum) = applied_checksum else { return Ok(()); }; if !LEGACY_AI_PROVIDER_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(&applied_checksum).as_str()) { return Ok(()); } let ai_settings_columns: Vec<(String, String, i64, Option, i64)> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('ai_settings') ORDER BY cid", ) .fetch_all(pool) .await?; let provider_config_columns: Vec<( String, String, i64, Option, i64, )> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('ai_provider_configs') ORDER BY cid", ) .fetch_all(pool) .await?; let provider_model_columns: Vec<( String, String, i64, Option, i64, )> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('ai_provider_models') ORDER BY cid", ) .fetch_all(pool) .await?; let translation_columns: Vec = sqlx::query_scalar( "SELECT name FROM pragma_table_info('translation_settings') WHERE name IN ('ai_provider_id', 'ai_model_id') ORDER BY name", ) .fetch_all(pool) .await?; let ai_settings_match = ai_settings_columns == vec![ ("id".to_string(), "INTEGER".to_string(), 1, None, 1), ( "enabled".to_string(), "INTEGER".to_string(), 1, Some("TRUE".to_string()), 0, ), ]; let provider_config_match = provider_config_columns == vec![ ("provider_id".to_string(), "TEXT".to_string(), 1, None, 1), ("custom_name".to_string(), "TEXT".to_string(), 0, None, 0), ( "protocol".to_string(), "TEXT".to_string(), 1, Some("'openai'".to_string()), 0, ), ( "enabled".to_string(), "INTEGER".to_string(), 1, Some("FALSE".to_string()), 0, ), ( "endpoint".to_string(), "TEXT".to_string(), 1, Some("''".to_string()), 0, ), ( "settings".to_string(), "TEXT".to_string(), 1, Some("'{}'".to_string()), 0, ), ]; let provider_model_match = provider_model_columns == vec![ ("provider_id".to_string(), "TEXT".to_string(), 1, None, 1), ("model_id".to_string(), "TEXT".to_string(), 1, None, 2), ( "display_name".to_string(), "TEXT".to_string(), 1, Some("''".to_string()), 0, ), ( "enabled".to_string(), "INTEGER".to_string(), 1, Some("TRUE".to_string()), 0, ), ( "source".to_string(), "TEXT".to_string(), 1, Some("'custom'".to_string()), 0, ), ]; if !ai_settings_match || !provider_config_match || !provider_model_match || translation_columns != ["ai_model_id", "ai_provider_id"] { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| migration.version == AI_PROVIDER_MIGRATION_VERSION) .expect("AI provider migration should be embedded"); update_migration_checksum( pool, AI_PROVIDER_MIGRATION_VERSION, migration.checksum.as_ref(), ) .await?; tracing::warn!( version = AI_PROVIDER_MIGRATION_VERSION, "Reconciled the validated legacy AI provider schema" ); Ok(()) } async fn reconcile_legacy_official_preferred_download_source_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let applied_checksum: Option> = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ? AND success = TRUE", ) .bind(OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION) .fetch_optional(pool) .await?; let Some(applied_checksum) = applied_checksum else { return Ok(()); }; if !LEGACY_OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(&applied_checksum).as_str()) { return Ok(()); } let source_columns: Vec<(String, String, i64, Option, i64)> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('settings') WHERE name IN ( 'minecraft_metadata_source', 'minecraft_file_source', 'modrinth_source', 'curseforge_source' )", ) .fetch_all(pool) .await?; let source_column_names = [ "minecraft_metadata_source", "minecraft_file_source", "modrinth_source", "curseforge_source", ]; let source_columns_match = source_columns.len() == source_column_names.len() && source_columns.iter().all( |(name, data_type, not_null, default_value, primary_key)| { source_column_names.contains(&name.as_str()) && data_type.eq_ignore_ascii_case("TEXT") && *not_null == 1 && default_value.as_deref() == Some("'auto'") && *primary_key == 0 }, ); let settings_sql: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'settings'", ) .fetch_optional(pool) .await?; let source_checks_match = settings_sql.as_ref().is_some_and(|sql| { source_column_names.iter().all(|column| { sql.contains(&format!( "{column} IN ('auto', 'official_only', 'mirror_preferred', 'official_preferred')" )) }) }); let minimal_home_foreign_key_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM pragma_foreign_key_list('settings') WHERE \"from\" = 'minimal_home_instance_id' AND \"table\" = 'instances' AND \"to\" = 'id' AND on_delete = 'SET NULL' )", ) .fetch_one(pool) .await?; let legacy_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'settings_with_official_preferred' )", ) .fetch_one(pool) .await?; if !source_columns_match || !source_checks_match || !minimal_home_foreign_key_exists || legacy_table_exists { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| { migration.version == OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION }) .expect( "official-preferred download source migration should be embedded", ); update_migration_checksum( pool, OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION, migration.checksum.as_ref(), ) .await?; tracing::warn!( version = OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION, "Reconciled the validated official-preferred download source schema" ); Ok(()) } async fn reconcile_legacy_provider_qualified_content_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let applied_checksum: Option> = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ? AND success = TRUE", ) .bind(PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION) .fetch_optional(pool) .await?; let Some(applied_checksum) = applied_checksum else { return Ok(()); }; if !LEGACY_PROVIDER_QUALIFIED_CONTENT_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(&applied_checksum).as_str()) { return Ok(()); } let schema_is_legacy_provider_qualified: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'content_entry_id' AND pk = 1) AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'provider' AND pk = 2) AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'provider_project_id') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'provider_release_id') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'is_origin') AND NOT EXISTS(SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'id') AND NOT EXISTS(SELECT 1 FROM pragma_table_info('instance_content_entries') WHERE name IN ('project_id', 'version_id')) AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_update_checks') WHERE name = 'provider') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_update_checks') WHERE name = 'provider_project_id') AND EXISTS(SELECT 1 FROM pragma_table_info('instance_content_update_checks') WHERE name = 'provider_release_id')", ) .fetch_one(pool) .await?; if !schema_is_legacy_provider_qualified { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| { migration.version == PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION }) .expect("provider-qualified content migration should be embedded"); update_migration_checksum( pool, PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION, migration.checksum.as_ref(), ) .await?; tracing::warn!( version = PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION, "Reconciled the validated legacy provider-qualified content schema" ); Ok(()) } async fn reconcile_existing_home_dashboard_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let applied_checksum: Option> = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ? AND success = TRUE", ) .bind(COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION) .fetch_optional(pool) .await?; let checksum_is_known = applied_checksum.as_ref().is_some_and(|checksum| { COLLIDING_HOME_DASHBOARD_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(checksum).as_str()) }); if !checksum_is_known { return Ok(()); } let pinned_at_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('instances') WHERE name = 'pinned_at')", ) .fetch_one(pool) .await?; let playtime_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_daily_playtime')", ) .fetch_one(pool) .await?; let playtime_index_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'instance_daily_playtime_played_on')", ) .fetch_one(pool) .await?; if !pinned_at_exists || !playtime_table_exists || !playtime_index_exists { return Ok(()); } let colliding_migration = MIGRATOR .iter() .find(|migration| { migration.version == COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION }) .expect("transparent background blur migration should be embedded"); let home_dashboard_migration = MIGRATOR .iter() .find(|migration| migration.version == HOME_DASHBOARD_MIGRATION_VERSION) .expect("home dashboard migration should be embedded"); let mut transaction = pool.begin().await?; let blur_column_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('settings') WHERE name = 'transparent_background_blur')", ) .fetch_one(&mut *transaction) .await?; if !blur_column_exists { sqlx::query( "ALTER TABLE settings ADD COLUMN transparent_background_blur INTEGER NOT NULL DEFAULT FALSE", ) .execute(&mut *transaction) .await?; } sqlx::query( "UPDATE _sqlx_migrations SET description = ?, checksum = ? WHERE version = ?", ) .bind(colliding_migration.description.as_ref()) .bind(colliding_migration.checksum.as_ref()) .bind(COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION) .execute(&mut *transaction) .await?; let canonical_applied: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(HOME_DASHBOARD_MIGRATION_VERSION) .fetch_one(&mut *transaction) .await?; if !canonical_applied { sqlx::query( "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, ?, TRUE, ?, 0)", ) .bind(home_dashboard_migration.version) .bind(home_dashboard_migration.description.as_ref()) .bind(home_dashboard_migration.checksum.as_ref()) .execute(&mut *transaction) .await?; } transaction.commit().await?; tracing::warn!( old_version = COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION, canonical_version = HOME_DASHBOARD_MIGRATION_VERSION, "Reconciled colliding Home dashboard migration version" ); Ok(()) } async fn reconcile_colliding_java_discovery_migration( pool: &Pool, ) -> crate::Result<()> { let onboarding_version_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('settings') WHERE name = 'onboarding_version')", ) .fetch_one(pool) .await?; if !onboarding_version_exists { sqlx::query( "ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0", ) .execute(pool) .await?; } let instance_tour_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('settings') WHERE name = 'onboarding_instance_tour_completed')", ) .fetch_one(pool) .await?; if !instance_tour_exists { sqlx::query( "ALTER TABLE settings ADD COLUMN onboarding_instance_tour_completed INTEGER NOT NULL DEFAULT TRUE", ) .execute(pool) .await?; sqlx::query( "UPDATE settings SET onboarding_instance_tour_completed = CASE WHEN onboarded = 1 THEN TRUE ELSE FALSE END", ) .execute(pool) .await?; } Ok(()) } async fn reconcile_existing_java_discovery_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let columns: Vec<(String, String, i64, i64)> = sqlx::query_as( "SELECT name, type, \"notnull\", pk FROM pragma_table_info('discovered_javas') ORDER BY cid", ) .fetch_all(pool) .await?; let expected_columns = [ ("path", "TEXT", 1, 1), ("major_version", "INTEGER", 1, 0), ("full_version", "TEXT", 1, 0), ("architecture", "TEXT", 1, 0), ("file_size", "INTEGER", 1, 0), ("file_mtime_ms", "INTEGER", 1, 0), ]; let schema_matches = columns.len() == expected_columns.len() && columns.iter().zip(expected_columns).all( |((name, data_type, not_null, primary_key), expected)| { name == expected.0 && data_type.eq_ignore_ascii_case(expected.1) && *not_null == expected.2 && *primary_key == expected.3 }, ); if !schema_matches { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| migration.version == JAVA_DISCOVERY_MIGRATION_VERSION) .expect("Java discovery migration should be embedded"); let canonical_applied: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(JAVA_DISCOVERY_MIGRATION_VERSION) .fetch_one(pool) .await?; let temporary_checksum: Option> = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(TEMPORARY_JAVA_DISCOVERY_MIGRATION_VERSION) .fetch_optional(pool) .await?; let temporary_is_known = temporary_checksum.as_ref().is_some_and(|checksum| { TEMPORARY_JAVA_DISCOVERY_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(checksum).as_str()) }); let mut transaction = pool.begin().await?; sqlx::query( "CREATE INDEX IF NOT EXISTS discovered_javas_major_version ON discovered_javas (major_version)", ) .execute(&mut *transaction) .await?; if !canonical_applied { sqlx::query( "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, ?, TRUE, ?, 0)", ) .bind(migration.version) .bind(migration.description.as_ref()) .bind(migration.checksum.as_ref()) .execute(&mut *transaction) .await?; } if temporary_is_known { sqlx::query("DELETE FROM _sqlx_migrations WHERE version = ?") .bind(TEMPORARY_JAVA_DISCOVERY_MIGRATION_VERSION) .execute(&mut *transaction) .await?; } transaction.commit().await?; tracing::warn!( version = JAVA_DISCOVERY_MIGRATION_VERSION, removed_temporary_version = temporary_is_known, "Reconciled existing Java discovery table with canonical migration" ); Ok(()) } async fn reconcile_existing_proxy_config_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let migration_applied: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(PROXY_CONFIG_MIGRATION_VERSION) .fetch_one(pool) .await?; if migration_applied { return Ok(()); } let columns: Vec<(String, String, i64, Option, i64)> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('settings') WHERE name IN ( 'proxy_mode', 'proxy_url', 'proxy_username', 'proxy_password' ) ORDER BY cid", ) .fetch_all(pool) .await?; let expected_columns = [ ("proxy_mode", "TEXT", 1, Some("'system'"), 0), ("proxy_url", "TEXT", 1, Some("''"), 0), ("proxy_username", "TEXT", 1, Some("''"), 0), ("proxy_password", "TEXT", 1, Some("''"), 0), ]; let columns_match = columns.len() == expected_columns.len() && columns.iter().zip(expected_columns).all( |( (name, data_type, not_null, default_value, primary_key), expected, )| { name == expected.0 && data_type.eq_ignore_ascii_case(expected.1) && *not_null == expected.2 && default_value.as_deref() == expected.3 && *primary_key == expected.4 }, ); if !columns_match { return Ok(()); } let settings_sql: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'settings'", ) .fetch_optional(pool) .await?; if !settings_sql.as_deref().is_some_and(|sql| { sql.contains("CHECK (proxy_mode IN ('none', 'system', 'custom'))") }) { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| migration.version == PROXY_CONFIG_MIGRATION_VERSION) .expect("Proxy config migration should be embedded"); sqlx::query( "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES (?, ?, TRUE, ?, 0)", ) .bind(migration.version) .bind(migration.description.as_ref()) .bind(migration.checksum.as_ref()) .execute(pool) .await?; tracing::warn!( version = PROXY_CONFIG_MIGRATION_VERSION, "Reconciled fully applied proxy config schema with canonical migration" ); Ok(()) } async fn reconcile_existing_content_favorites_migration( pool: &Pool, ) -> crate::Result<()> { let has_migrations_table: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations')", ) .fetch_one(pool) .await?; if !has_migrations_table { return Ok(()); } let migration_applied: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(CONTENT_FAVORITES_MIGRATION_VERSION) .fetch_one(pool) .await?; if migration_applied { return Ok(()); } let columns: Vec<(String, String, i64, Option, i64)> = sqlx::query_as( "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('content_favorites') ORDER BY cid", ) .fetch_all(pool) .await?; let expected_columns = [ ("provider", "TEXT", 1, None, 1), ("project_id", "TEXT", 1, None, 2), ("content_type", "TEXT", 1, None, 0), ("saved_at", "INTEGER", 1, None, 0), ]; let columns_match = columns.len() == expected_columns.len() && columns.iter().zip(expected_columns).all( |( (name, data_type, not_null, default_value, primary_key), expected, )| { name == expected.0 && data_type.eq_ignore_ascii_case(expected.1) && *not_null == expected.2 && default_value.as_deref() == expected.3 && *primary_key == expected.4 }, ); if !columns_match { return Ok(()); } let table_sql: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'content_favorites'", ) .fetch_optional(pool) .await?; if !table_sql.as_deref().is_some_and(|sql| { sql.contains("CHECK (provider IN ('modrinth', 'curseforge'))") && sql.contains("CHECK (length(project_id) > 0)") && sql.contains( "CHECK (content_type IN ('mod', 'resourcepack', 'datapack', 'shader'))", ) && sql.contains("PRIMARY KEY (provider, project_id)") }) { return Ok(()); } let indexes: Vec<(String, i64, String, i64)> = sqlx::query_as( "SELECT name, \"unique\", origin, partial FROM pragma_index_list('content_favorites') ORDER BY name", ) .fetch_all(pool) .await?; let indexes_match = indexes.len() == 2 && indexes.iter().any(|index| { index.0 == "content_favorites_saved_at_idx" && index.1 == 0 && index.2 == "c" && index.3 == 0 }) && indexes.iter().any(|index| { index.0.starts_with("sqlite_autoindex_content_favorites_") && index.1 == 1 && index.2 == "pk" && index.3 == 0 }); if !indexes_match { return Ok(()); } let saved_at_index_columns: Vec<(String, i64)> = sqlx::query_as( "SELECT name, desc FROM pragma_index_xinfo('content_favorites_saved_at_idx') WHERE key = 1 ORDER BY seqno", ) .fetch_all(pool) .await?; if saved_at_index_columns != [("saved_at".to_string(), 1)] { return Ok(()); } let foreign_key_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM pragma_foreign_key_list('content_favorites')", ) .fetch_one(pool) .await?; if foreign_key_count != 0 { return Ok(()); } let migration = MIGRATOR .iter() .find(|migration| { migration.version == CONTENT_FAVORITES_MIGRATION_VERSION }) .expect("Content favorites migration should be embedded"); sqlx::query( "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES (?, ?, TRUE, ?, 0)", ) .bind(migration.version) .bind(migration.description.as_ref()) .bind(migration.checksum.as_ref()) .execute(pool) .await?; tracing::warn!( version = CONTENT_FAVORITES_MIGRATION_VERSION, "Reconciled fully applied content favorites schema with canonical migration" ); Ok(()) } fn is_compatible_migration_checksum( version: i64, applied_checksum: &[u8], migration: &Migration, ) -> bool { let normalized_lf = migration.sql.replace("\r\n", "\n").replace('\r', "\n"); let normalized_crlf = normalized_lf.replace('\n', "\r\n"); if checksum_matches(applied_checksum, normalized_lf.as_bytes()) || checksum_matches(applied_checksum, normalized_crlf.as_bytes()) { return true; } version == INITIAL_MIGRATION_VERSION && LEGACY_INITIAL_MIGRATION_CHECKSUMS .contains(&checksum_as_hex(applied_checksum).as_str()) } fn checksum_matches(checksum: &[u8], contents: &[u8]) -> bool { let calculated: [u8; 48] = Sha384::digest(contents).into(); checksum == calculated } fn checksum_as_hex(checksum: &[u8]) -> String { use std::fmt::Write; checksum.iter().fold( String::with_capacity(checksum.len() * 2), |mut output, byte| { let _ = write!(output, "{byte:02x}"); output }, ) } async fn open_app_db_pool(db_path: &Path) -> crate::Result> { let conn_options = SqliteConnectOptions::new() .filename(db_path) .busy_timeout(Duration::from_secs(30)) .journal_mode(SqliteJournalMode::Wal) .optimize_on_close(true, None) .create_if_missing(true); Ok(SqlitePoolOptions::new() .max_connections(100) .connect_with(conn_options) .await?) } async fn record_current_app_version(pool: &Pool) -> crate::Result<()> { sqlx::query!( " INSERT INTO app_metadata (key, value, updated_at) VALUES ('app_version', ?, unixepoch()) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ", env!("CARGO_PKG_VERSION"), ) .execute(pool) .await?; Ok(()) } /// Cleans up data from the database that is no longer referenced, but must be /// kept around for a little while to allow users to recover from accidental /// deletions. async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> { let mut tx = pool.begin().await?; let has_skin_tables = sqlx::query!( "SELECT COUNT(*) AS \"count!: i64\" FROM sqlite_master WHERE type = 'table' AND name IN ('custom_minecraft_skins', 'minecraft_users')", ) .fetch_one(&mut *tx) .await? .count == 2; if has_skin_tables { sqlx::query!( "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" ) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; const TERRACOTTA_PUBLIC_NODES_MIGRATION_VERSION: i64 = 20260812120000; const INSTANCE_LOADER_COMPONENTS_MIGRATION_VERSION: i64 = 20260819120000; const INSTANCE_POST_UPGRADE_NOTICES_MIGRATION_VERSION: i64 = 20260824000000; const MCARCHIVE_PROVIDER_FILE_ID_MIGRATION_VERSION: i64 = 20260823020000; #[tokio::test] async fn channel_copy_replaces_database_and_removes_stale_sidecars() { let temp = tempfile::tempdir().unwrap(); let source = temp.path().join("beta").join(LEGACY_APP_DB_FILE); let target = temp.path().join("release").join(LEGACY_APP_DB_FILE); tokio::fs::create_dir_all(source.parent().unwrap()) .await .unwrap(); tokio::fs::create_dir_all(target.parent().unwrap()) .await .unwrap(); for (path, marker) in [(&source, "beta"), (&target, "release")] { let pool = open_app_db_pool(path).await.unwrap(); sqlx::query("CREATE TABLE marker (value TEXT NOT NULL)") .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO marker (value) VALUES (?)") .bind(marker) .execute(&pool) .await .unwrap(); pool.close().await; } tokio::fs::write(sidecar_path(&target, "-wal"), b"stale wal") .await .unwrap(); tokio::fs::write(sidecar_path(&target, "-shm"), b"stale shm") .await .unwrap(); copy_app_database(&source, &target).await.unwrap(); assert!(!sidecar_path(&target, "-wal").exists()); assert!(!sidecar_path(&target, "-shm").exists()); let pool = open_app_db_pool(&target).await.unwrap(); let marker: String = sqlx::query_scalar("SELECT value FROM marker") .fetch_one(&pool) .await .unwrap(); pool.close().await; assert_eq!(marker, "beta"); assert!(!sidecar_path(&target, ".copying").exists()); assert!(!sidecar_path(&target, ".replacing").exists()); } #[tokio::test] async fn post_upgrade_notices_migrate_fresh_and_existing_databases() { for previous_schema in [false, true] { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); if previous_schema { let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < INSTANCE_POST_UPGRADE_NOTICES_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); } MIGRATOR.run(&pool).await.unwrap(); let table_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_post_upgrade_notices')", ) .fetch_one(&pool) .await .unwrap(); assert!(table_exists); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } } #[tokio::test] async fn loader_components_migrate_fresh_and_existing_instances() { let fresh = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&fresh) .await .unwrap(); MIGRATOR.run(&fresh).await.unwrap(); let table_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_loader_components' )", ) .fetch_one(&fresh) .await .unwrap(); assert!(table_exists); let fresh_foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&fresh) .await .unwrap(); assert!(fresh_foreign_key_errors.is_empty()); let upgraded = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&upgraded) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < INSTANCE_LOADER_COMPONENTS_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&upgraded).await.unwrap(); for (id, loader, loader_version) in [ ("forge", "forge", Some("14.23.5.2860")), ("optifine", "optifine", Some("HD_U_G5")), ("vanilla", "vanilla", None), ] { let content_set_id = format!("{id}-set"); sqlx::query( "INSERT INTO instances ( id, path, applied_content_set_id, install_stage, launcher_feature_version, update_channel, name, created, modified, submitted_time_played, recent_time_played ) VALUES (?, ?, ?, 'installed', 'migrated_launch_hooks', 'release', ?, 1, 1, 0, 0)", ) .bind(id) .bind(id) .bind(&content_set_id) .bind(id) .execute(&upgraded) .await .unwrap(); sqlx::query( "INSERT INTO instance_content_sets ( id, instance_id, name, source_kind, status, game_version, loader, loader_version, created, modified ) VALUES (?, ?, 'Default', 'local', 'available', '1.12.2', ?, ?, 1, 1)", ) .bind(&content_set_id) .bind(id) .bind(loader) .bind(loader_version) .execute(&upgraded) .await .unwrap(); } MIGRATOR.run(&upgraded).await.unwrap(); let rows: Vec<(String, String, Option, String)> = sqlx::query_as( "SELECT instance_id, kind, version, role FROM instance_loader_components ORDER BY instance_id, role DESC, kind", ) .fetch_all(&upgraded) .await .unwrap(); assert_eq!( rows, vec![ ( "forge".to_string(), "forge".to_string(), Some("14.23.5.2860".to_string()), "primary".to_string(), ), ( "optifine".to_string(), "vanilla".to_string(), None, "primary".to_string(), ), ( "optifine".to_string(), "optifine".to_string(), Some("HD_U_G5".to_string()), "adjunct".to_string(), ), ( "vanilla".to_string(), "vanilla".to_string(), None, "primary".to_string(), ), ] ); let malformed_metadata = sqlx::query( "UPDATE instance_loader_components SET provider_metadata = '{' WHERE instance_id = 'forge'", ) .execute(&upgraded) .await; assert!(malformed_metadata.is_err()); let upgraded_foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&upgraded) .await .unwrap(); assert!(upgraded_foreign_key_errors.is_empty()); } #[tokio::test] async fn mcarchive_provider_file_ids_migrate_fresh_and_preserve_existing_references() { let fresh = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&fresh) .await .unwrap(); MIGRATOR.run(&fresh).await.unwrap(); let fresh_has_file_id: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'provider_file_id' )", ) .fetch_one(&fresh) .await .unwrap(); assert!(fresh_has_file_id); let upgraded = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&upgraded) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < MCARCHIVE_PROVIDER_FILE_ID_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&upgraded).await.unwrap(); sqlx::raw_sql( r#" INSERT INTO instances ( id, path, applied_content_set_id, install_stage, launcher_feature_version, name, created, modified ) VALUES ('mcarchive-migration-instance', 'mcarchive-migration-instance', 'mcarchive-migration-set', 'installed', '1', 'MCArchive migration', 1, 1); INSERT INTO instance_content_sets ( id, instance_id, name, source_kind, status, game_version, loader, created, modified ) VALUES ('mcarchive-migration-set', 'mcarchive-migration-instance', 'Default', 'local', 'available', '1.6.2', 'vanilla', 1, 1); UPDATE instances SET applied_content_set_id = 'mcarchive-migration-set' WHERE id = 'mcarchive-migration-instance'; INSERT INTO instance_content_entries ( id, instance_id, content_set_id, file_id, project_type, source_kind, server_requirement, client_requirement, enabled, added_at, modified_at ) VALUES ('modrinth-reference', 'mcarchive-migration-instance', 'mcarchive-migration-set', NULL, 'mod', 'local', 'required', 'required', 1, 1, 1), ('curseforge-reference', 'mcarchive-migration-instance', 'mcarchive-migration-set', NULL, 'mod', 'local', 'required', 'required', 1, 1, 1), ('mcarchive-reference', 'mcarchive-migration-instance', 'mcarchive-migration-set', NULL, 'mod', 'mcarchive', 'required', 'required', 1, 1, 1); INSERT INTO instance_content_provider_refs ( content_entry_id, provider, provider_project_id, provider_release_id, is_origin ) VALUES ('modrinth-reference', 'modrinth', 'modrinth-project', 'modrinth-version', 1), ('curseforge-reference', 'curseforge', '42', '7', 1), ('mcarchive-reference', 'mcarchive', 'project-uuid', 'version-uuid', 1); "#, ) .execute(&upgraded) .await .unwrap(); MIGRATOR.run(&upgraded).await.unwrap(); let references: Vec<(String, String, Option, Option)> = sqlx::query_as( "SELECT provider, provider_project_id, provider_release_id, provider_file_id FROM instance_content_provider_refs ORDER BY provider", ) .fetch_all(&upgraded) .await .unwrap(); assert_eq!( references, vec![ ( "curseforge".to_string(), "42".to_string(), Some("7".to_string()), Some("7".to_string()), ), ( "mcarchive".to_string(), "project-uuid".to_string(), Some("version-uuid".to_string()), None, ), ( "modrinth".to_string(), "modrinth-project".to_string(), Some("modrinth-version".to_string()), None, ), ] ); sqlx::query( "INSERT INTO instance_content_provider_refs ( content_entry_id, provider, provider_project_id, provider_release_id, provider_file_id, is_origin ) VALUES (?, 'mcarchive', 'project-uuid', 'version-uuid', 'file-uuid', 0)", ) .bind("mcarchive-reference") .execute(&upgraded) .await .unwrap(); let mcarchive_file_id: Option = sqlx::query_scalar( "SELECT provider_file_id FROM instance_content_provider_refs WHERE content_entry_id = 'mcarchive-reference' AND provider_file_id = 'file-uuid'", ) .fetch_one(&upgraded) .await .unwrap(); assert_eq!(mcarchive_file_id.as_deref(), Some("file-uuid")); let old_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_content_provider_refs_old' )", ) .fetch_one(&upgraded) .await .unwrap(); assert!(!old_table_exists); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&upgraded) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[allow(dead_code)] async fn settings_snapshot(pool: &Pool) -> Vec<(String, String)> { let columns: Vec = sqlx::query_scalar( "SELECT name FROM pragma_table_info('settings') ORDER BY cid", ) .fetch_all(pool) .await .unwrap(); let mut snapshot = Vec::with_capacity(columns.len()); for column in columns { let escaped = column.replace('"', "\"\""); let value: String = sqlx::query_scalar(&format!( "SELECT quote(\"{escaped}\") FROM settings WHERE id = 0" )) .fetch_one(pool) .await .unwrap(); snapshot.push((column, value)); } snapshot } #[tokio::test] async fn terracotta_public_nodes_migration_preserves_existing_settings() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < TERRACOTTA_PUBLIC_NODES_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query("UPDATE settings SET locale = 'zh-TW' WHERE id = 0") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let (locale, nodes): (String, String) = sqlx::query_as( "SELECT locale, json(terracotta_public_nodes) FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(locale, "zh-TW"); assert_eq!(nodes, "[\"wss://center.node.1tmc.top\"]"); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn curseforge_download_restriction_bypass_migration_defaults_on() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CURSEFORGE_DOWNLOAD_RESTRICTION_BYPASS_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query("UPDATE settings SET locale = 'zh-CN' WHERE id = 0") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let (locale, bypass): (String, bool) = sqlx::query_as( "SELECT locale, bypass_curseforge_download_restrictions FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(locale, "zh-CN"); assert!(bypass); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } fn initial_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| migration.version == INITIAL_MIGRATION_VERSION) .expect("initial migration should be embedded") } fn provider_qualified_content_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION }) .expect("provider-qualified content migration should be embedded") } fn official_preferred_download_source_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION }) .expect( "official-preferred download source migration should be embedded", ) } fn ai_provider_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == AI_PROVIDER_MIGRATION_VERSION }) .expect("AI provider migration should be embedded") } fn reconcile_provider_qualified_content_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == RECONCILE_PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION }) .expect( "provider content reconciliation migration should be embedded", ) } fn java_default_versions_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == JAVA_DEFAULT_VERSIONS_MIGRATION_VERSION }) .expect("Java default versions migration should be embedded") } fn instance_content_ownership_migration() -> &'static Migration { MIGRATOR .iter() .find(|migration| { migration.version == INSTANCE_CONTENT_OWNERSHIP_MIGRATION_VERSION }) .expect("instance content ownership migration should be embedded") } async fn create_previous_java_versions_schema(pool: &Pool) { sqlx::raw_sql( " CREATE TABLE java_versions ( major_version INTEGER NOT NULL, full_version TEXT NOT NULL, architecture TEXT NOT NULL, path TEXT NOT NULL PRIMARY KEY, distribution TEXT ); CREATE INDEX idx_java_versions_major_version ON java_versions(major_version); ", ) .execute(pool) .await .unwrap(); } fn checksum(contents: &[u8]) -> Vec { Sha384::digest(contents).to_vec() } fn decode_hex(value: &str) -> Vec { value .as_bytes() .chunks_exact(2) .map(|pair| { let pair = std::str::from_utf8(pair).expect("ASCII hex"); u8::from_str_radix(pair, 16).expect("valid hex") }) .collect() } #[test] fn accepts_lf_and_crlf_variants_of_the_same_migration() { let migration = initial_migration(); let lf = migration.sql.replace("\r\n", "\n").replace('\r', "\n"); let crlf = lf.replace('\n', "\r\n"); assert!(is_compatible_migration_checksum( migration.version, &checksum(lf.as_bytes()), migration, )); assert!(is_compatible_migration_checksum( migration.version, &checksum(crlf.as_bytes()), migration, )); } #[test] fn accepts_only_the_known_legacy_initial_migration() { let migration = initial_migration(); let legacy_checksum = decode_hex(LEGACY_INITIAL_MIGRATION_CHECKSUMS[0]); assert!(is_compatible_migration_checksum( INITIAL_MIGRATION_VERSION, &legacy_checksum, migration, )); assert!(!is_compatible_migration_checksum( INITIAL_MIGRATION_VERSION + 1, &legacy_checksum, migration, )); } #[test] fn rejects_an_unknown_content_change() { let migration = initial_migration(); let changed_checksum = checksum( format!("{}\n-- unknown change", migration.sql).as_bytes(), ); assert!(!is_compatible_migration_checksum( migration.version, &changed_checksum, migration, )); } #[tokio::test] async fn reconciles_official_preferred_migration_with_trailing_blank_line() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query( "UPDATE settings SET theme = 'light', locale = 'zh-CN' WHERE id = 0", ) .execute(&pool) .await .unwrap(); let migration = official_preferred_download_source_migration(); let legacy_sql = format!("{}\n", migration.sql); let legacy_checksum = checksum(legacy_sql.as_bytes()); assert_eq!( checksum_as_hex(&legacy_checksum), LEGACY_OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_CHECKSUMS[0] ); sqlx::raw_sql(&legacy_sql).execute(&pool).await.unwrap(); sqlx::query( "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES (?, ?, TRUE, ?, 0)", ) .bind(migration.version) .bind(migration.description.as_ref()) .bind(&legacy_checksum) .execute(&pool) .await .unwrap(); reconcile_legacy_official_preferred_download_source_migration(&pool) .await .unwrap(); let reconciled_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(migration.version) .fetch_one(&pool) .await .unwrap(); assert_eq!(reconciled_checksum, migration.checksum.as_ref()); MIGRATOR.run(&pool).await.unwrap(); let proxy_mode: String = sqlx::query_scalar("SELECT proxy_mode FROM settings WHERE id = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_mode, "system"); let proxy_url: String = sqlx::query_scalar("SELECT proxy_url FROM settings WHERE id = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_url, ""); let proxy_username: String = sqlx::query_scalar( "SELECT proxy_username FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_username, ""); let proxy_password: String = sqlx::query_scalar( "SELECT proxy_password FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_password, ""); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn reconciles_known_ai_provider_checksum_for_matching_schema() { const DISCARD_LEGACY_OPENAI_MIGRATION_VERSION: i64 = 20260805130000; let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let provider_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < DISCARD_LEGACY_OPENAI_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; provider_migrator.run(&pool).await.unwrap(); let migration = ai_provider_migration(); let legacy_checksum = decode_hex(LEGACY_AI_PROVIDER_MIGRATION_CHECKSUMS[0]); sqlx::query( "UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?", ) .bind(&legacy_checksum) .bind(migration.version) .execute(&pool) .await .unwrap(); reconcile_legacy_ai_provider_migration(&pool).await.unwrap(); let reconciled_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(migration.version) .fetch_one(&pool) .await .unwrap(); assert_eq!(reconciled_checksum, migration.checksum.as_ref()); MIGRATOR.run(&pool).await.unwrap(); let cleanup_column: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM pragma_table_info('ai_settings') WHERE name = 'legacy_openai_credential_cleanup'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(cleanup_column, 1); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn rejects_legacy_official_preferred_checksum_for_old_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); let migration = official_preferred_download_source_migration(); let legacy_checksum = decode_hex( LEGACY_OFFICIAL_PREFERRED_DOWNLOAD_SOURCE_MIGRATION_CHECKSUMS[0], ); sqlx::query( "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES (?, ?, TRUE, ?, 0)", ) .bind(migration.version) .bind(migration.description.as_ref()) .bind(&legacy_checksum) .execute(&pool) .await .unwrap(); reconcile_legacy_official_preferred_download_source_migration(&pool) .await .unwrap(); let stored_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(migration.version) .fetch_one(&pool) .await .unwrap(); assert_eq!(stored_checksum, legacy_checksum); assert!(MIGRATOR.run(&pool).await.is_err()); } #[test] fn embedded_migration_versions_are_unique() { let mut versions = HashSet::new(); for migration in MIGRATOR.iter() { assert!( versions.insert(migration.version), "duplicate migration version {}", migration.version ); } } #[tokio::test] async fn reconciles_fully_applied_proxy_config_without_migration_record() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < PROXY_CONFIG_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); let migration = MIGRATOR .iter() .find(|migration| { migration.version == PROXY_CONFIG_MIGRATION_VERSION }) .unwrap(); sqlx::raw_sql(migration.sql.as_ref()) .execute(&pool) .await .unwrap(); sqlx::query( "UPDATE settings SET proxy_mode = 'custom', proxy_url = 'http://localhost:7890', proxy_username = 'user', proxy_password = 'secret' WHERE id = 0", ) .execute(&pool) .await .unwrap(); reconcile_existing_proxy_config_migration(&pool) .await .unwrap(); let stored_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(PROXY_CONFIG_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert_eq!(stored_checksum, migration.checksum.as_ref()); MIGRATOR.run(&pool).await.unwrap(); let settings: (String, String, String, String) = sqlx::query_as( "SELECT proxy_mode, proxy_url, proxy_username, proxy_password FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( settings, ( "custom".to_string(), "http://localhost:7890".to_string(), "user".to_string(), "secret".to_string(), ) ); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn does_not_reconcile_incomplete_proxy_config_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < PROXY_CONFIG_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::raw_sql( "ALTER TABLE settings ADD COLUMN proxy_mode TEXT NOT NULL DEFAULT 'system'; ALTER TABLE settings ADD COLUMN proxy_url TEXT NOT NULL DEFAULT ''; ALTER TABLE settings ADD COLUMN proxy_username TEXT NOT NULL DEFAULT ''; ALTER TABLE settings ADD COLUMN proxy_password TEXT NOT NULL DEFAULT '';", ) .execute(&pool) .await .unwrap(); reconcile_existing_proxy_config_migration(&pool) .await .unwrap(); let migration_recorded: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(PROXY_CONFIG_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert!(!migration_recorded); assert!(MIGRATOR.run(&pool).await.is_err()); } #[tokio::test] async fn reconciles_fully_applied_content_favorites_without_migration_record() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_FAVORITES_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); let migration = MIGRATOR .iter() .find(|migration| { migration.version == CONTENT_FAVORITES_MIGRATION_VERSION }) .unwrap(); sqlx::raw_sql(migration.sql.as_ref()) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO content_favorites ( provider, project_id, content_type, saved_at ) VALUES ('modrinth', 'modrinth-project', 'mod', 10), ('curseforge', 'curseforge-project', 'resourcepack', 20)", ) .execute(&pool) .await .unwrap(); reconcile_existing_content_favorites_migration(&pool) .await .unwrap(); let stored_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(CONTENT_FAVORITES_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert_eq!(stored_checksum, migration.checksum.as_ref()); MIGRATOR.run(&pool).await.unwrap(); let favorites: Vec<(String, String, String, i64)> = sqlx::query_as( "SELECT provider, project_id, content_type, saved_at FROM content_favorites ORDER BY provider", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( favorites, [ ( "curseforge".to_string(), "curseforge-project".to_string(), "resourcepack".to_string(), 20, ), ( "modrinth".to_string(), "modrinth-project".to_string(), "mod".to_string(), 10, ), ] ); sqlx::query( "INSERT INTO content_favorites ( provider, project_id, content_type, saved_at ) VALUES ('mcarchive', 'mcarchive-project', 'mod', 30)", ) .execute(&pool) .await .unwrap(); let old_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'content_favorites_old' )", ) .fetch_one(&pool) .await .unwrap(); assert!(!old_table_exists); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn does_not_reconcile_incomplete_content_favorites_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_FAVORITES_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::raw_sql( "CREATE TABLE content_favorites ( provider TEXT NOT NULL, project_id TEXT NOT NULL, content_type TEXT NOT NULL, saved_at INTEGER NOT NULL, PRIMARY KEY (provider, project_id) ); CREATE INDEX content_favorites_saved_at_idx ON content_favorites (saved_at DESC);", ) .execute(&pool) .await .unwrap(); reconcile_existing_content_favorites_migration(&pool) .await .unwrap(); let migration_recorded: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(CONTENT_FAVORITES_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert!(!migration_recorded); assert!(MIGRATOR.run(&pool).await.is_err()); } #[tokio::test] async fn proxy_config_migration_preserves_existing_settings() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < SYSTEM_PROXY_SETTING_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query( " UPDATE settings SET max_concurrent_downloads = 17, theme = 'light', locale = 'zh-CN', minecraft_metadata_source = 'official_only', minecraft_file_source = 'mirror_preferred', modrinth_source = 'auto', curseforge_source = 'official_only' WHERE id = 0 ", ) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let proxy_mode: String = sqlx::query_scalar("SELECT proxy_mode FROM settings WHERE id = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_mode, "system"); let proxy_url: String = sqlx::query_scalar("SELECT proxy_url FROM settings WHERE id = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_url, ""); let proxy_username: String = sqlx::query_scalar( "SELECT proxy_username FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_username, ""); let proxy_password: String = sqlx::query_scalar( "SELECT proxy_password FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_password, ""); let proxy_column_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name = 'use_system_proxy'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(proxy_column_count, 0); let legacy_table_exists: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'settings_with_official_preferred' )", ) .fetch_one(&pool) .await .unwrap(); assert!(!legacy_table_exists); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); sqlx::query( " UPDATE settings SET minecraft_metadata_source = 'official_preferred', minecraft_file_source = 'official_preferred', modrinth_source = 'official_preferred', curseforge_source = 'official_preferred' WHERE id = 0 ", ) .execute(&pool) .await .unwrap(); let modes: (String, String, String, String) = sqlx::query_as( " SELECT minecraft_metadata_source, minecraft_file_source, modrinth_source, curseforge_source FROM settings WHERE id = 0 ", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( modes, ( "official_preferred".to_string(), "official_preferred".to_string(), "official_preferred".to_string(), "official_preferred".to_string(), ) ); assert!( sqlx::query( "UPDATE settings SET minecraft_file_source = 'invalid' WHERE id = 0" ) .execute(&pool) .await .is_err() ); } #[tokio::test] async fn upgrades_instance_content_ownership_without_losing_files() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < INSTANCE_CONTENT_OWNERSHIP_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::raw_sql( r#" INSERT INTO instances ( id, path, applied_content_set_id, install_stage, launcher_feature_version, name, created, modified ) VALUES ('cf', 'cf', NULL, 'installed', '1', 'CF Pack', 1, 1), ('mr', 'mr', NULL, 'installed', '1', 'MR Pack', 1, 1), ('imported', 'imported', NULL, 'installed', '1', 'Imported Pack', 1, 1); INSERT INTO instance_content_sets ( id, instance_id, name, source_kind, status, game_version, loader, created, modified ) VALUES ('cf-set', 'cf', 'Default', 'curseforge', 'applied', '1.12.2', 'forge', 1, 1), ('mr-set', 'mr', 'Default', 'modrinth_modpack', 'applied', '1.20.1', 'fabric', 1, 1), ('imported-set', 'imported', 'Default', 'imported_modpack', 'applied', '1.20.1', 'fabric', 1, 1); UPDATE instances SET applied_content_set_id = id || '-set'; INSERT INTO instance_links ( instance_id, link_kind, modrinth_project_id, modrinth_version_id, curseforge_project_id, curseforge_file_id, imported_name, imported_filename ) VALUES ('cf', 'curseforge_modpack', NULL, NULL, 285109, 4612979, NULL, NULL), ('mr', 'modrinth_modpack', 'mr-pack', 'mr-pack-version', NULL, NULL, NULL, NULL), ('imported', 'imported_modpack', NULL, NULL, NULL, NULL, 'Imported Pack', 'pack.zip'); INSERT INTO instance_files ( id, instance_id, relative_path, file_name, enabled, sha1, size, missing, added_at, modified_at ) VALUES ('cf-pack-file', 'cf', 'mods/cf-pack.jar', 'cf-pack.jar', 1, 'cf-pack-sha1', 10, 0, 1, 1), ('cf-added-file', 'cf', 'mods/cf-added.jar', 'cf-added.jar', 1, 'cf-added-sha1', 11, 0, 1, 1), ('cf-duplicate-file', 'cf', 'mods/cf-pack-old.jar', 'cf-pack-old.jar', 1, 'cf-pack-old-sha1', 10, 1, 1, 2), ('mr-pack-file', 'mr', 'mods/mr-pack.jar', 'mr-pack.jar', 1, 'mr-pack-sha1', 12, 0, 1, 1), ('mr-added-file', 'mr', 'mods/mr-added.jar', 'mr-added.jar', 1, 'mr-added-sha1', 13, 0, 1, 1), ('imported-file', 'imported', 'mods/imported.jar', 'imported.jar', 0, 'imported-sha1', 14, 0, 1, 1), ('imported-duplicate-file', 'imported', 'MODS\IMPORTED.JAR', 'IMPORTED.JAR', 1, 'imported-duplicate-sha1', 14, 1, 1, 2); INSERT INTO instance_content_entries ( id, instance_id, content_set_id, file_id, project_type, source_kind, server_requirement, client_requirement, enabled, added_at, modified_at ) VALUES ('cf-pack-entry', 'cf', 'cf-set', 'cf-pack-file', 'mod', 'curseforge', 'required', 'required', 1, 1, 1), ('cf-added-entry', 'cf', 'cf-set', 'cf-added-file', 'mod', 'curseforge', 'required', 'required', 1, 1, 1), ('cf-duplicate-entry', 'cf', 'cf-set', 'cf-duplicate-file', 'mod', 'curseforge', 'required', 'required', 1, 1, 2), ('mr-pack-entry', 'mr', 'mr-set', 'mr-pack-file', 'mod', 'modrinth_modpack', 'required', 'required', 1, 1, 1), ('mr-added-entry', 'mr', 'mr-set', 'mr-added-file', 'mod', 'local', 'required', 'required', 1, 1, 1), ('imported-entry', 'imported', 'imported-set', 'imported-file', 'mod', 'imported_modpack', 'optional', 'optional', 0, 1, 1), ('imported-duplicate-entry', 'imported', 'imported-set', 'imported-duplicate-file', 'mod', 'imported_modpack', 'optional', 'optional', 1, 1, 2); INSERT INTO instance_content_provider_refs ( content_entry_id, provider, provider_project_id, provider_release_id, is_origin ) VALUES ('cf-pack-entry', 'curseforge', '123', '456', 1), ('cf-duplicate-entry', 'curseforge', '123', '455', 1), ('cf-added-entry', 'curseforge', '789', '987', 1), ('mr-pack-entry', 'modrinth', 'mr-project', 'mr-version', 1); "#, ) .execute(&pool) .await .unwrap(); let file_count_before: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instance_files") .fetch_one(&pool) .await .unwrap(); reconcile_pending_instance_content_ownership_duplicates(&pool) .await .unwrap(); let reclassified_entries: Vec<(String, String)> = sqlx::query_as( "SELECT id, source_kind FROM instance_content_entries WHERE id IN ('cf-duplicate-entry', 'imported-duplicate-entry') ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( reclassified_entries, vec![ ("cf-duplicate-entry".to_string(), "local".to_string()), ("imported-duplicate-entry".to_string(), "local".to_string(),), ] ); sqlx::raw_sql(instance_content_ownership_migration().sql.as_ref()) .execute(&pool) .await .unwrap(); let ownership: Vec<(String, String)> = sqlx::query_as( "SELECT id, ownership_kind FROM instance_content_entries ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( ownership, vec![ ("cf-added-entry".to_string(), "pack_managed".to_string()), ("cf-duplicate-entry".to_string(), "user_added".to_string(),), ("cf-pack-entry".to_string(), "pack_managed".to_string()), ( "imported-duplicate-entry".to_string(), "user_added".to_string(), ), ("imported-entry".to_string(), "pack_managed".to_string()), ("mr-added-entry".to_string(), "user_added".to_string()), ("mr-pack-entry".to_string(), "pack_managed".to_string()), ] ); let members: Vec<(String, Option, i64, String)> = sqlx::query_as( "SELECT content_entry_id, provider_project_id, reconciled, override_kind FROM instance_pack_members ORDER BY content_entry_id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(members.len(), 4); assert!(members.iter().any(|member| { member.0 == "cf-pack-entry" && member.1.as_deref() == Some("123") && member.2 == 0 })); assert!(members.iter().any(|member| { member.0 == "cf-added-entry" && member.1.as_deref() == Some("789") && member.2 == 0 })); assert!(members.iter().any(|member| { member.0 == "imported-entry" && member.1.is_none() && member.3 == "disabled" })); let provider_refs: Vec<(String, String, String, Option)> = sqlx::query_as( "SELECT content_entry_id, provider, provider_project_id, provider_release_id FROM instance_content_provider_refs ORDER BY content_entry_id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( provider_refs, vec![ ( "cf-added-entry".to_string(), "curseforge".to_string(), "789".to_string(), Some("987".to_string()), ), ( "cf-duplicate-entry".to_string(), "curseforge".to_string(), "123".to_string(), Some("455".to_string()), ), ( "cf-pack-entry".to_string(), "curseforge".to_string(), "123".to_string(), Some("456".to_string()), ), ( "mr-pack-entry".to_string(), "modrinth".to_string(), "mr-project".to_string(), Some("mr-version".to_string()), ), ] ); let file_count_after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instance_files") .fetch_one(&pool) .await .unwrap(); assert_eq!(file_count_after, file_count_before); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn creates_java_default_versions_for_a_fresh_database() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); reconcile_pending_instance_content_ownership_duplicates(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let defaults: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM java_default_versions") .fetch_one(&pool) .await .unwrap(); assert_eq!(defaults, 0); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn upgrades_multiple_java_installations_with_stable_defaults() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); create_previous_java_versions_schema(&pool).await; sqlx::raw_sql( " INSERT INTO java_versions ( major_version, full_version, architecture, path, distribution ) VALUES (21, '21.0.7', 'aarch64', '/java/zulu-21', 'Azul'), (21, '21.0.8', 'aarch64', '/java/temurin-21', 'Eclipse'), (17, '17.0.12', 'x86_64', '/java/custom-17', 'Unknown / Custom'), (8, '1.8.0_402', 'x86_64', '/java/java-8', NULL); ", ) .execute(&pool) .await .unwrap(); sqlx::raw_sql(java_default_versions_migration().sql.as_ref()) .execute(&pool) .await .unwrap(); let defaults: Vec<(i64, String)> = sqlx::query_as( "SELECT major_version, path FROM java_default_versions ORDER BY major_version DESC", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( defaults, vec![ (21, "/java/temurin-21".to_string()), (17, "/java/custom-17".to_string()), (8, "/java/java-8".to_string()), ] ); let mismatched_default = sqlx::query( "INSERT INTO java_default_versions (major_version, path) VALUES (25, '/java/java-8')", ) .execute(&pool) .await; assert!(mismatched_default.is_err()); sqlx::query( "DELETE FROM java_versions WHERE path = '/java/temurin-21'", ) .execute(&pool) .await .unwrap(); let java_21_default: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM java_default_versions WHERE major_version = 21", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(java_21_default, 0); let remaining_java_versions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM java_versions") .fetch_one(&pool) .await .unwrap(); assert_eq!(remaining_java_versions, 3); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn upgrades_legacy_content_schema_without_provider_leakage() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); sqlx::raw_sql( r#" CREATE TABLE instances (id TEXT PRIMARY KEY); CREATE TABLE instance_content_sets (id TEXT PRIMARY KEY); CREATE TABLE instance_files ( id TEXT PRIMARY KEY, relative_path TEXT NOT NULL ); CREATE TABLE cache ( data_type TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE instance_content_entries ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, content_set_id TEXT NOT NULL, file_id TEXT NULL, project_type TEXT NOT NULL, project_id TEXT NULL, version_id TEXT NULL, source_kind TEXT NOT NULL, server_requirement TEXT NOT NULL, client_requirement TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, added_at INTEGER NOT NULL, modified_at INTEGER NOT NULL, FOREIGN KEY (instance_id) REFERENCES instances(id), FOREIGN KEY (content_set_id) REFERENCES instance_content_sets(id), FOREIGN KEY (file_id) REFERENCES instance_files(id) ); CREATE INDEX instance_content_entries_instance_id ON instance_content_entries(instance_id); CREATE INDEX instance_content_entries_content_set_id ON instance_content_entries(content_set_id); CREATE INDEX instance_content_entries_file_id ON instance_content_entries(file_id); CREATE INDEX instance_content_entries_project_id ON instance_content_entries(project_id); CREATE INDEX instance_content_entries_version_id ON instance_content_entries(version_id); CREATE INDEX instance_content_entries_source_kind ON instance_content_entries(source_kind); CREATE TABLE instance_content_update_checks ( content_entry_id TEXT PRIMARY KEY, update_channel TEXT NOT NULL, update_version_id TEXT NULL, checked_at INTEGER NOT NULL, FOREIGN KEY (content_entry_id) REFERENCES instance_content_entries(id) ); CREATE INDEX instance_content_update_checks_update_version_id ON instance_content_update_checks(update_version_id); CREATE TABLE instance_content_provider_refs ( content_entry_id TEXT NOT NULL, provider TEXT NOT NULL, project_id TEXT NOT NULL, version_id TEXT NULL, primary_ref INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (content_entry_id, provider), FOREIGN KEY (content_entry_id) REFERENCES instance_content_entries(id) ); CREATE INDEX instance_content_provider_refs_project ON instance_content_provider_refs(provider, project_id); CREATE INDEX instance_content_provider_refs_version ON instance_content_provider_refs(provider, version_id); CREATE UNIQUE INDEX instance_content_provider_refs_primary ON instance_content_provider_refs(content_entry_id) WHERE primary_ref = 1; INSERT INTO instances (id) VALUES ('instance'); INSERT INTO instance_content_sets (id) VALUES ('set'); INSERT INTO instance_files (id, relative_path) VALUES ('cf-file', 'mods/cf.jar'), ('cf-proven-file', 'mods/cf-proven.jar'), ('mr-file', 'mods/mr.jar'), ('broken-file', 'mods/broken.jar'); INSERT INTO instance_content_entries ( id, instance_id, content_set_id, file_id, project_type, project_id, version_id, source_kind, server_requirement, client_requirement, enabled, added_at, modified_at ) VALUES ('cf', 'instance', 'set', 'cf-file', 'mod', '42', '7', 'curseforge', 'required', 'required', 1, 1, 1), ('cf-proven', 'instance', 'set', 'cf-proven-file', 'mod', '99', '11', 'curseforge', 'required', 'required', 1, 1, 1), ('mr', 'instance', 'set', 'mr-file', 'mod', 'mr-project', 'mr-version', 'local', 'required', 'required', 1, 1, 1), ('broken', 'instance', 'set', 'broken-file', 'mod', 'not-a-number', 'also-invalid', 'curseforge', 'required', 'required', 1, 1, 1); INSERT INTO instance_content_provider_refs ( content_entry_id, provider, project_id, version_id, primary_ref ) VALUES ('cf', 'modrinth', '42', '7', 1), ('cf-proven', 'modrinth', '99', '11', 1), ('mr', 'modrinth', 'mr-project', 'mr-version', 1), ('broken', 'modrinth', 'not-a-number', 'also-invalid', 1); INSERT INTO instance_content_update_checks ( content_entry_id, update_channel, update_version_id, checked_at ) VALUES ('cf', 'release', '8', 1), ('mr', 'release', 'mr-update', 1); INSERT INTO cache (data_type, data) VALUES ( 'file_hash', '{"path":"instance/mods/cf-proven.jar","project_id":"verified-mr-project","version_id":"verified-mr-version"}' ); "#, ) .execute(&pool) .await .unwrap(); sqlx::raw_sql(provider_qualified_content_migration().sql.as_ref()) .execute(&pool) .await .unwrap(); sqlx::raw_sql( reconcile_provider_qualified_content_migration() .sql .as_ref(), ) .execute(&pool) .await .unwrap(); let refs: Vec<(String, String, String, Option, i64)> = sqlx::query_as( "SELECT content_entry_id, provider, provider_project_id, provider_release_id, is_origin FROM instance_content_provider_refs ORDER BY content_entry_id, provider, provider_project_id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( refs, vec![ ( "cf".to_string(), "curseforge".to_string(), "42".to_string(), Some("7".to_string()), 1, ), ( "cf-proven".to_string(), "curseforge".to_string(), "99".to_string(), Some("11".to_string()), 1, ), ( "cf-proven".to_string(), "modrinth".to_string(), "verified-mr-project".to_string(), Some("verified-mr-version".to_string()), 0, ), ( "mr".to_string(), "modrinth".to_string(), "mr-project".to_string(), Some("mr-version".to_string()), 1, ), ] ); let update_checks: Vec<(String, String, String, String)> = sqlx::query_as( "SELECT content_entry_id, provider, provider_project_id, provider_release_id FROM instance_content_update_checks", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( update_checks, vec![( "mr".to_string(), "modrinth".to_string(), "mr-project".to_string(), "mr-update".to_string(), )] ); let legacy_tables: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE '%_legacy'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(legacy_tables, 0); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn reconciles_applied_single_provider_schema_before_forward_migration() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::raw_sql( " CREATE TABLE _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL ); CREATE TABLE instance_content_entries ( id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, file_id TEXT NULL, source_kind TEXT NOT NULL ); CREATE TABLE instance_files ( id TEXT PRIMARY KEY, relative_path TEXT NOT NULL ); CREATE TABLE cache ( data_type TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE instance_content_provider_refs ( content_entry_id TEXT NOT NULL, provider TEXT NOT NULL, provider_project_id TEXT NOT NULL, provider_release_id TEXT NULL, is_origin INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (content_entry_id, provider) ); CREATE INDEX instance_content_provider_refs_project ON instance_content_provider_refs( provider, provider_project_id ); CREATE INDEX instance_content_provider_refs_release ON instance_content_provider_refs( provider, provider_release_id ); CREATE UNIQUE INDEX instance_content_provider_refs_origin ON instance_content_provider_refs(content_entry_id) WHERE is_origin = 1; CREATE TABLE instance_content_update_checks ( content_entry_id TEXT PRIMARY KEY, provider TEXT NULL, provider_project_id TEXT NULL, provider_release_id TEXT NULL ); INSERT INTO instance_content_entries ( id, instance_id, file_id, source_kind ) VALUES ('cf', 'instance', 'file', 'curseforge'); INSERT INTO instance_files (id, relative_path) VALUES ('file', 'mods/cf.jar'); INSERT INTO instance_content_provider_refs ( content_entry_id, provider, provider_project_id, provider_release_id, is_origin ) VALUES ('cf', 'modrinth', '42', '7', 1); ", ) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO _sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES (?, 'provider qualified content', TRUE, ?, 0)", ) .bind(PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION) .bind(decode_hex( LEGACY_PROVIDER_QUALIFIED_CONTENT_MIGRATION_CHECKSUMS[0], )) .execute(&pool) .await .unwrap(); reconcile_legacy_provider_qualified_content_migration(&pool) .await .unwrap(); let applied_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(PROVIDER_QUALIFIED_CONTENT_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert_eq!( applied_checksum, provider_qualified_content_migration().checksum.as_ref() ); sqlx::raw_sql( reconcile_provider_qualified_content_migration() .sql .as_ref(), ) .execute(&pool) .await .unwrap(); let refs: Vec<(String, String, String, Option, i64)> = sqlx::query_as( "SELECT content_entry_id, provider, provider_project_id, provider_release_id, is_origin FROM instance_content_provider_refs", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( refs, vec![( "cf".to_string(), "curseforge".to_string(), "42".to_string(), Some("7".to_string()), 1, )] ); let has_id: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM pragma_table_info('instance_content_provider_refs') WHERE name = 'id' AND pk = 1 )", ) .fetch_one(&pool) .await .unwrap(); assert!(has_id); } #[tokio::test] async fn reconciles_colliding_home_dashboard_migration() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query( " CREATE TABLE _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL ); CREATE TABLE settings (id INTEGER PRIMARY KEY); CREATE TABLE instances (id TEXT PRIMARY KEY, pinned_at INTEGER NULL); CREATE TABLE instance_daily_playtime ( played_on TEXT NOT NULL, instance_id TEXT NOT NULL, instance_name TEXT NOT NULL, played_seconds INTEGER NOT NULL DEFAULT 0, session_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (played_on, instance_id) ); CREATE INDEX instance_daily_playtime_played_on ON instance_daily_playtime(played_on); ", ) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, 'home-dashboard', TRUE, ?, 0)", ) .bind(COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION) .bind(decode_hex( COLLIDING_HOME_DASHBOARD_MIGRATION_CHECKSUMS[0], )) .execute(&pool) .await .unwrap(); reconcile_existing_home_dashboard_migration(&pool) .await .unwrap(); reconcile_existing_home_dashboard_migration(&pool) .await .unwrap(); let blur_column_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('settings') WHERE name = 'transparent_background_blur')", ) .fetch_one(&pool) .await .unwrap(); assert!(blur_column_exists); for version in [ COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION, HOME_DASHBOARD_MIGRATION_VERSION, ] { let migration = MIGRATOR .iter() .find(|migration| migration.version == version) .unwrap(); let (description, checksum): (String, Vec) = sqlx::query_as( "SELECT description, checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(version) .fetch_one(&pool) .await .unwrap(); assert_eq!(description, migration.description.as_ref()); assert_eq!(checksum, migration.checksum.as_ref()); } } #[tokio::test] async fn does_not_claim_incomplete_home_dashboard_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query( " CREATE TABLE _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL ); CREATE TABLE settings (id INTEGER PRIMARY KEY); CREATE TABLE instances (id TEXT PRIMARY KEY, pinned_at INTEGER NULL); ", ) .execute(&pool) .await .unwrap(); let legacy_checksum = decode_hex(COLLIDING_HOME_DASHBOARD_MIGRATION_CHECKSUMS[0]); sqlx::query( "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, 'home-dashboard', TRUE, ?, 0)", ) .bind(COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION) .bind(&legacy_checksum) .execute(&pool) .await .unwrap(); reconcile_existing_home_dashboard_migration(&pool) .await .unwrap(); let canonical_applied: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(HOME_DASHBOARD_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert!(!canonical_applied); let applied_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(COLLIDING_HOME_DASHBOARD_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert_eq!(applied_checksum, legacy_checksum); } #[tokio::test] async fn repairs_schema_from_colliding_java_discovery_migration() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query( "CREATE TABLE settings (onboarded INTEGER NOT NULL DEFAULT 0)", ) .execute(&pool) .await .unwrap(); sqlx::query("INSERT INTO settings (onboarded) VALUES (0), (1)") .execute(&pool) .await .unwrap(); reconcile_colliding_java_discovery_migration(&pool) .await .unwrap(); let values: Vec<(i64, i64)> = sqlx::query_as( "SELECT onboarding_version, onboarding_instance_tour_completed FROM settings ORDER BY onboarded", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(values, vec![(0, 0), (0, 1)]); } #[tokio::test] async fn claims_existing_java_table_for_canonical_migration() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query( " CREATE TABLE _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL ) ", ) .execute(&pool) .await .unwrap(); sqlx::query( " CREATE TABLE discovered_javas ( path TEXT NOT NULL PRIMARY KEY, major_version INTEGER NOT NULL, full_version TEXT NOT NULL, architecture TEXT NOT NULL, file_size INTEGER NOT NULL, file_mtime_ms INTEGER NOT NULL ) ", ) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (?, 'temporary', TRUE, ?, 0)", ) .bind(TEMPORARY_JAVA_DISCOVERY_MIGRATION_VERSION) .bind(decode_hex( TEMPORARY_JAVA_DISCOVERY_MIGRATION_CHECKSUMS[0], )) .execute(&pool) .await .unwrap(); reconcile_existing_java_discovery_migration(&pool) .await .unwrap(); let migration = MIGRATOR .iter() .find(|migration| { migration.version == JAVA_DISCOVERY_MIGRATION_VERSION }) .unwrap(); let canonical_checksum: Vec = sqlx::query_scalar( "SELECT checksum FROM _sqlx_migrations WHERE version = ?", ) .bind(JAVA_DISCOVERY_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert_eq!(canonical_checksum, migration.checksum.as_ref()); let temporary_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(TEMPORARY_JAVA_DISCOVERY_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert!(!temporary_exists); let index_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'discovered_javas_major_version')", ) .fetch_one(&pool) .await .unwrap(); assert!(index_exists); } #[tokio::test] async fn does_not_claim_incompatible_java_table() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query( " CREATE TABLE _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL ) ", ) .execute(&pool) .await .unwrap(); sqlx::query("CREATE TABLE discovered_javas (path TEXT PRIMARY KEY)") .execute(&pool) .await .unwrap(); reconcile_existing_java_discovery_migration(&pool) .await .unwrap(); let canonical_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = ?)", ) .bind(JAVA_DISCOVERY_MIGRATION_VERSION) .fetch_one(&pool) .await .unwrap(); assert!(!canonical_exists); } #[tokio::test] async fn upgrades_previous_database_with_nullable_home_widgets() { const HOME_WIDGETS_MIGRATION_VERSION: i64 = 20260804120000; let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < HOME_WIDGETS_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query( "UPDATE settings SET locale = 'zh-CN', home_layout = 'minimal' WHERE id = 0", ) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let upgraded: (String, String, Option) = sqlx::query_as( "SELECT locale, home_layout, json(home_widgets) FROM settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(upgraded, ("zh-CN".into(), "minimal".into(), None)); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn creates_ai_provider_schema_for_a_fresh_database() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let enabled: i64 = sqlx::query_scalar("SELECT enabled FROM ai_settings WHERE id = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(enabled, 1); let legacy_credential_cleanup: i64 = sqlx::query_scalar( "SELECT legacy_openai_credential_cleanup FROM ai_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(legacy_credential_cleanup, 0); let ai_tables: Vec = sqlx::query_scalar( "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'ai_%' ORDER BY name", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( ai_tables, vec![ "ai_provider_configs".to_string(), "ai_provider_models".to_string(), "ai_settings".to_string(), ] ); let translation_ai_columns: Vec = sqlx::query_scalar( "SELECT name FROM pragma_table_info('translation_settings') WHERE name IN ('ai_provider_id', 'ai_model_id') ORDER BY name", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( translation_ai_columns, vec!["ai_model_id".to_string(), "ai_provider_id".to_string()] ); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn discards_legacy_openai_translation_configuration() { const AI_PROVIDER_MIGRATION_VERSION: i64 = 20260805120000; let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < AI_PROVIDER_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query( "UPDATE translation_settings SET provider = 'openai-compatible', target_language = 'zh-CN', openai_base_url = 'https://gateway.example.test/v1', openai_model = 'example-chat-model', openai_api_key = 'legacy-secret', openai_system_prompt = 'Preserve terminology.' WHERE id = 0", ) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO translation_cache (key, translation, created_at) VALUES ('legacy-cache', 'cached translation', 123)", ) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let translation: (String, String, String, String, Option) = sqlx::query_as( "SELECT provider, ai_provider_id, ai_model_id, openai_system_prompt, openai_api_key FROM translation_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( translation, ( "google".to_string(), String::new(), String::new(), String::new(), None, ) ); let provider_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ai_provider_configs") .fetch_one(&pool) .await .unwrap(); let model_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ai_provider_models") .fetch_one(&pool) .await .unwrap(); assert_eq!((provider_count, model_count), (0, 0)); let legacy_credential_cleanup: i64 = sqlx::query_scalar( "SELECT legacy_openai_credential_cleanup FROM ai_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(legacy_credential_cleanup, 1); let cached: (String, i64) = sqlx::query_as( "SELECT translation, created_at FROM translation_cache WHERE key = 'legacy-cache'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(cached, ("cached translation".to_string(), 123)); let legacy_objects: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sqlite_master WHERE (type = 'table' OR type = 'index') AND name = 'legacy_openai_ai_config'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(legacy_objects, 0); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn ignores_malformed_legacy_ai_provider_configuration() { const AI_PROVIDER_MIGRATION_VERSION: i64 = 20260805120000; let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < AI_PROVIDER_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); sqlx::query( "UPDATE translation_settings SET provider = 'openai-compatible', openai_base_url = 'not a URL', openai_model = '', openai_api_key = NULL WHERE id = 0", ) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let translation: ( String, String, String, String, String, Option, String, ) = sqlx::query_as( "SELECT provider, ai_provider_id, ai_model_id, openai_base_url, openai_model, openai_api_key, openai_system_prompt FROM translation_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( translation, ( "google".to_string(), String::new(), String::new(), "https://api.openai.com/v1".to_string(), "gpt-4o-mini".to_string(), None, String::new(), ) ); let provider_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ai_provider_configs") .fetch_one(&pool) .await .unwrap(); let model_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ai_provider_models") .fetch_one(&pool) .await .unwrap(); assert_eq!((provider_count, model_count), (0, 0)); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn preserves_ai_configuration_created_after_provider_migration() { const DISCARD_LEGACY_OPENAI_MIGRATION_VERSION: i64 = 20260805130000; let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let provider_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < DISCARD_LEGACY_OPENAI_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; provider_migrator.run(&pool).await.unwrap(); sqlx::query( "INSERT INTO ai_provider_configs (provider_id, custom_name, protocol, enabled, endpoint, settings) VALUES ('openai', NULL, 'openai', TRUE, 'https://api.openai.com/v1', '{}')", ) .execute(&pool) .await .unwrap(); sqlx::query( "INSERT INTO ai_provider_models (provider_id, model_id, display_name, enabled, source) VALUES ('openai', 'gpt-5-mini', 'GPT-5 mini', TRUE, 'builtin')", ) .execute(&pool) .await .unwrap(); sqlx::query( "UPDATE translation_settings SET provider = 'ai', ai_provider_id = 'openai', ai_model_id = 'gpt-5-mini', openai_system_prompt = 'Current AI prompt.' WHERE id = 0", ) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let provider: (String, String, i64) = sqlx::query_as( "SELECT provider_id, endpoint, enabled FROM ai_provider_configs WHERE provider_id = 'openai'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( provider, ( "openai".to_string(), "https://api.openai.com/v1".to_string(), 1, ) ); let model: (String, String, String) = sqlx::query_as( "SELECT provider_id, model_id, source FROM ai_provider_models WHERE provider_id = 'openai'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( model, ( "openai".to_string(), "gpt-5-mini".to_string(), "builtin".to_string(), ) ); let translation: (String, String, String, String) = sqlx::query_as( "SELECT provider, ai_provider_id, ai_model_id, openai_system_prompt FROM translation_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( translation, ( "ai".to_string(), "openai".to_string(), "gpt-5-mini".to_string(), "Current AI prompt.".to_string(), ) ); let legacy_credential_cleanup: i64 = sqlx::query_scalar( "SELECT legacy_openai_credential_cleanup FROM ai_settings WHERE id = 0", ) .fetch_one(&pool) .await .unwrap(); assert_eq!(legacy_credential_cleanup, 0); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_edges_migration_creates_fresh_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let has_auto_dependency: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM pragma_table_info('instance_content_entries') WHERE name = 'auto_dependency' )", ) .fetch_one(&pool) .await .unwrap(); assert!(has_auto_dependency); let has_edges: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_content_dependencies' )", ) .fetch_one(&pool) .await .unwrap(); assert!(has_edges); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_edges_migration_upgrades_previous_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_DEPENDENCY_EDGES_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); let had_edges: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_content_dependencies' )", ) .fetch_one(&pool) .await .unwrap(); assert!(!had_edges); MIGRATOR.run(&pool).await.unwrap(); let has_edges: bool = sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'instance_content_dependencies' )", ) .fetch_one(&pool) .await .unwrap(); assert!(has_edges); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } async fn insert_dependency_edge_fixture(pool: &sqlx::SqlitePool) { sqlx::raw_sql( r#" INSERT INTO instances ( id, path, applied_content_set_id, install_stage, launcher_feature_version, name, created, modified ) VALUES ('local-edge-instance', 'local-edge-instance', NULL, 'installed', '1', 'Local Edge', 1, 1); INSERT INTO instance_content_sets ( id, instance_id, name, source_kind, status, game_version, loader, created, modified ) VALUES ('local-edge-set', 'local-edge-instance', 'Default', 'local', 'applied', '1.20.1', 'fabric', 1, 1); UPDATE instances SET applied_content_set_id = 'local-edge-set'; INSERT INTO instance_content_entries ( id, instance_id, content_set_id, file_id, project_type, source_kind, server_requirement, client_requirement, enabled, added_at, modified_at ) VALUES ('parent-entry', 'local-edge-instance', 'local-edge-set', NULL, 'mod', 'local', 'required', 'required', 1, 1, 1), ('child-entry', 'local-edge-instance', 'local-edge-set', NULL, 'mod', 'local', 'required', 'required', 1, 1, 1), ('child-entry-local', 'local-edge-instance', 'local-edge-set', NULL, 'mod', 'local', 'required', 'required', 1, 1, 1); "#, ) .execute(pool) .await .unwrap(); } fn dependency_edge_row_legacy( provider: &str, child_entry_id: &str, child_project_id: &str, ) -> String { format!( "INSERT INTO instance_content_dependencies ( id, content_set_id, parent_entry_id, child_entry_id, provider, dependency_kind, parent_project_id, parent_release_id, child_project_id, child_release_id, created_at, modified_at ) VALUES ('edge-{provider}', 'local-edge-set', 'parent-entry', '{child_entry_id}', '{provider}', 'required', 'local:parent', '1.0.0', '{child_project_id}', '2.0.0', 1, 1)" ) } fn dependency_edge_row( evidence_provider: &str, parent_provider: &str, child_provider: &str, child_entry_id: &str, child_project_id: &str, ) -> String { format!( "INSERT INTO instance_content_dependencies ( id, content_set_id, parent_entry_id, child_entry_id, evidence_provider, parent_provider, child_provider, dependency_kind, parent_project_id, parent_release_id, child_project_id, child_release_id, created_at, modified_at ) VALUES ('edge-{evidence_provider}-{child_entry_id}', 'local-edge-set', 'parent-entry', '{child_entry_id}', '{evidence_provider}', '{parent_provider}', '{child_provider}', 'required', 'local:parent', '1.0.0', '{child_project_id}', '2.0.0', 1, 1)" ) } #[tokio::test] async fn content_dependency_local_provider_migration_accepts_local_rows_fresh() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); insert_dependency_edge_fixture(&pool).await; sqlx::raw_sql(&dependency_edge_row( "local", "local", "local", "child-entry-local", "local:child-local", )) .execute(&pool) .await .unwrap(); let providers: Vec = sqlx::query_scalar( "SELECT evidence_provider FROM instance_content_dependencies", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(providers, ["local"]); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_local_provider_migration_upgrades_preserving_rows() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_DEPENDENCY_LOCAL_PROVIDER_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); insert_dependency_edge_fixture(&pool).await; sqlx::raw_sql(&dependency_edge_row_legacy( "modrinth", "child-entry", "local:child", )) .execute(&pool) .await .unwrap(); let local_row = dependency_edge_row_legacy( "local", "child-entry-local", "local:child-local", ) .replace("edge-local", "edge-local-before"); let local_before: Result = sqlx::raw_sql(&local_row).execute(&pool).await; assert!(local_before.is_err()); MIGRATOR.run(&pool).await.unwrap(); let providers: Vec = sqlx::query_scalar( "SELECT evidence_provider FROM instance_content_dependencies ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(providers, ["modrinth"]); sqlx::raw_sql(&dependency_edge_row( "local", "local", "local", "child-entry-local", "local:child-local", )) .execute(&pool) .await .unwrap(); let providers: Vec = sqlx::query_scalar( "SELECT evidence_provider FROM instance_content_dependencies ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!(providers, ["local", "modrinth"]); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_endpoint_providers_migration_creates_provider_qualified_schema() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); insert_dependency_edge_fixture(&pool).await; sqlx::raw_sql(&dependency_edge_row( "modrinth", "curseforge", "modrinth", "child-entry", "mr-child", )) .execute(&pool) .await .unwrap(); let providers: (String, String, String) = sqlx::query_as( "SELECT evidence_provider, parent_provider, child_provider FROM instance_content_dependencies", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( providers, ("modrinth".into(), "curseforge".into(), "modrinth".into()) ); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_endpoint_providers_migration_backfills_legacy_rows() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_DEPENDENCY_ENDPOINT_PROVIDERS_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); insert_dependency_edge_fixture(&pool).await; sqlx::raw_sql(&dependency_edge_row_legacy( "curseforge", "child-entry", "cf-child", )) .execute(&pool) .await .unwrap(); MIGRATOR.run(&pool).await.unwrap(); let legacy: (String, String, String) = sqlx::query_as( "SELECT evidence_provider, parent_provider, child_provider FROM instance_content_dependencies WHERE id = 'edge-curseforge'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( legacy, ( "curseforge".into(), "curseforge".into(), "curseforge".into() ) ); sqlx::raw_sql(&dependency_edge_row( "modrinth", "curseforge", "modrinth", "child-entry-local", "mr-child", )) .execute(&pool) .await .unwrap(); let cross_source: (String, String, String) = sqlx::query_as( "SELECT evidence_provider, parent_provider, child_provider FROM instance_content_dependencies WHERE id = 'edge-modrinth-child-entry-local'", ) .fetch_one(&pool) .await .unwrap(); assert_eq!( cross_source, ("modrinth".into(), "curseforge".into(), "modrinth".into()) ); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } #[tokio::test] async fn content_dependency_backfill_marker_migration_upgrades_preserving_rows() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); let previous_migrator = Migrator { migrations: std::borrow::Cow::Owned( MIGRATOR .iter() .filter(|migration| { migration.version < CONTENT_DEPENDENCY_BACKFILL_MARKER_MIGRATION_VERSION }) .cloned() .collect(), ), ..Migrator::DEFAULT }; previous_migrator.run(&pool).await.unwrap(); insert_dependency_edge_fixture(&pool).await; MIGRATOR.run(&pool).await.unwrap(); let markers: Vec<(String, i64)> = sqlx::query_as( "SELECT id, dependency_backfilled FROM instance_content_entries ORDER BY id", ) .fetch_all(&pool) .await .unwrap(); assert_eq!( markers, [ ("child-entry".to_string(), 0), ("child-entry-local".to_string(), 0), ("parent-entry".to_string(), 0), ] ); sqlx::query( "UPDATE instance_content_entries SET dependency_backfilled = 1 WHERE id = 'parent-entry'", ) .execute(&pool) .await .unwrap(); let foreign_key_errors: Vec<(String, i64, String, i64)> = sqlx::query_as("PRAGMA foreign_key_check") .fetch_all(&pool) .await .unwrap(); assert!(foreign_key_errors.is_empty()); } }