Files
Starlight_Lancher/packages/app-lib/src/state/db.rs
T
AxTps 8b94e7dbbe
Sync source to CNB / Sync Git ref (push) Skipped
fix: 更新渠道默认值跟随安装包版本
从 GitHub 下载 Beta 安装包安装后,更新通道默认选中 Release,
因为 update-channel.json 状态文件不存在时会硬编码回退到 release。

新增 theseus::default_update_channel(),按编译期版本号判断:含预发布
后缀(如 1.0.2-beta7)默认 Beta,否则 Release。数据库渠道与设置页
渠道两处默认值统一改为调用它,保证两者一致。
2026-10-13 22:11:06 +08:00

4835 lines
163 KiB
Rust

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<Pool<Sqlite>> {
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<bool> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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::<serde_json::Value>(&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<Pool<Sqlite>> {
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<Pool<Sqlite>> {
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<Sqlite>,
) -> 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<Sqlite>,
) -> 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<u8>)> =
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<Sqlite>,
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<Sqlite>,
) -> 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<Vec<u8>> = 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<String>, 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<String>,
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<String>,
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<String> = 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<Sqlite>,
) -> 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<Vec<u8>> = 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<String>, 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<String> = 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<Sqlite>,
) -> 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<Vec<u8>> = 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<Sqlite>,
) -> 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<Vec<u8>> = 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<Sqlite>,
) -> 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<Sqlite>,
) -> 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<Vec<u8>> = 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<Sqlite>,
) -> 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<String>, 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<String> = 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<Sqlite>,
) -> 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<String>, 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<String> = 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<Pool<Sqlite>> {
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<Sqlite>) -> 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<Sqlite>) -> 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>, 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<String>, Option<String>)> =
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<String> = 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<Sqlite>) -> Vec<(String, String)> {
let columns: Vec<String> = 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<Sqlite>) {
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<u8> {
Sha384::digest(contents).to_vec()
}
fn decode_hex(value: &str) -> Vec<u8> {
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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<String>, 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<String>)> =
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<String>, 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<u8> = 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<String>, 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<u8>) = 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<u8> = 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<u8> = 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<String>) = 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<String> = 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<String> = 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<String>) =
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>,
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<String> = 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::sqlite::SqliteQueryResult, _> =
sqlx::raw_sql(&local_row).execute(&pool).await;
assert!(local_before.is_err());
MIGRATOR.run(&pool).await.unwrap();
let providers: Vec<String> = 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<String> = 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());
}
}