From d593ecb09f4d46a8a74b18379b0feeaed1e48cf3 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Thu, 16 Jul 2026 01:49:33 -0400 Subject: [PATCH] 26.1.2 support --- Cargo.lock | 1 + packages/oneclient_app/src/bridge/runtime.rs | 1 + .../src/components/active_cluster_panel.rs | 2 +- .../src/components/cluster_landscape_art.rs | 5 +- .../src/components/dynamic_art.rs | 20 +- .../src/hooks/queries/version_metadata.rs | 77 ++++- .../oneclient_app/src/layout/cluster_shell.rs | 2 +- packages/oneclient_app/src/utils.rs | 56 ++-- .../src/view/app/clusters/page.rs | 53 ++-- .../oneclient_core/src/clusters/migrate.rs | 272 ++++++++++++++++++ packages/oneclient_core/src/clusters/mod.rs | 2 + .../oneclient_core/src/clusters/provision.rs | 2 +- packages/oneclient_core/src/lib.rs | 4 +- packages/oneclient_core/src/state.rs | 4 + packages/oneclient_core/src/version.rs | 73 ++++- .../oneclient_core/src/versions/manager.rs | 6 +- .../oneclient_core/src/versions/manifest.rs | 87 ++++++ packages/oneclient_core/src/versions/mod.rs | 4 +- ...8665f6be44e7937deffb921ddb6a5450805ab.json | 26 ++ ...3ddef96835a67aa8affe3453845a646e5ba61.json | 158 ++++++++++ ...3703ba78d72f39b3090734bb3e69c2b72d50c.json | 12 + ...c396b0ee319adec619e5a7d147805573ae2b2.json | 21 ++ packages/oneclient_db/Cargo.toml | 3 + .../20260715120000_applied_migrations.sql | 4 + .../oneclient_db/src/dao/applied_migration.rs | 24 ++ packages/oneclient_db/src/dao/cluster.rs | 133 +++++++++ packages/oneclient_db/src/dao/mod.rs | 1 + 27 files changed, 967 insertions(+), 86 deletions(-) create mode 100644 packages/oneclient_core/src/clusters/migrate.rs create mode 100644 packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json create mode 100644 packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json create mode 100644 packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json create mode 100644 packages/oneclient_db/.sqlx/query-dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2.json create mode 100644 packages/oneclient_db/migrations/20260715120000_applied_migrations.sql create mode 100644 packages/oneclient_db/src/dao/applied_migration.rs diff --git a/Cargo.lock b/Cargo.lock index 1b2edf4f..734f2f14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4599,6 +4599,7 @@ dependencies = [ "serde_json", "sqlx", "thiserror 2.0.18", + "tokio", "tracing", ] diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 919ee01a..0c3ea912 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -684,6 +684,7 @@ impl CoreBridgeRuntime { BridgeCommand::SyncBundles => { let state = LauncherState::get()?; state.bundles.sync(&state.services).await?; + oneclient_core::clusters::apply_remote_migrations(&state).await?; oneclient_core::clusters::ensure_from_bundles(&state).await?; let changed = oneclient_core::bundles::sync_all_cluster_bundles( state.bundles.as_ref(), diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 9e703b5b..61e01121 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -65,7 +65,7 @@ impl Component for ActiveClusterPanel { .and_then(|c| parse_mc_version(&c.mc_version)); let metadata = use_version_metadata( parsed.as_ref().map(|p| p.major), - parsed.and_then(|p| p.minor), + parsed.and_then(|p| p.key()), active.as_ref().map(|c| c.mc_loader), ); diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs index 8ae12e13..a9265687 100644 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -1,4 +1,5 @@ use freya::prelude::*; +use oneclient_core::VersionKey; use oneclient_core::packages::domain::GameLoader; use crate::components::DynamicArt; @@ -14,12 +15,12 @@ pub struct ClusterLandscapeArt { impl ClusterLandscapeArt { pub fn for_version( major: u32, - minor: Option, + key: Option, loader: Option, selected: bool, ) -> Self { Self { - art: DynamicArt::for_version(major, minor, loader).max_edge(512), + art: DynamicArt::for_version(major, key, loader).max_edge(512), selected, } } diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 636734c5..537a7db2 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -4,7 +4,7 @@ use freya::query::QueryStateData; use oneclient_core::clusters::Cluster; use oneclient_core::images::DEFAULT_IMAGE_EDGE; use oneclient_core::packages::domain::GameLoader; -use oneclient_core::parse_mc_version; +use oneclient_core::{VersionKey, parse_mc_version}; use crate::AppAssets; use crate::hooks::{use_cached_image, use_version_metadata}; @@ -13,16 +13,16 @@ use crate::layout::HOME_BACKGROUND_ASSET; #[derive(PartialEq, Clone)] pub struct DynamicArt { major: Option, - minor: Option, + key: Option, loader: Option, max_edge: u32, } impl DynamicArt { - pub fn for_version(major: u32, minor: Option, loader: Option) -> Self { + pub fn for_version(major: u32, key: Option, loader: Option) -> Self { Self { major: Some(major), - minor, + key, loader, max_edge: DEFAULT_IMAGE_EDGE, } @@ -31,7 +31,7 @@ impl DynamicArt { pub fn for_major(major: u32) -> Self { Self { major: Some(major), - minor: None, + key: None, loader: None, max_edge: DEFAULT_IMAGE_EDGE, } @@ -41,7 +41,7 @@ impl DynamicArt { let parsed = parse_mc_version(&cluster.mc_version); Self { major: parsed.as_ref().map(|p| p.major), - minor: parsed.and_then(|p| p.minor), + key: parsed.and_then(|p| p.key()), loader: Some(cluster.mc_loader), max_edge: DEFAULT_IMAGE_EDGE, } @@ -50,7 +50,7 @@ impl DynamicArt { pub fn fallback() -> Self { Self { major: None, - minor: None, + key: None, loader: None, max_edge: DEFAULT_IMAGE_EDGE, } @@ -63,19 +63,19 @@ impl DynamicArt { } pub fn use_bytes(&self) -> (String, Bytes) { - use_art_bytes(self.major, self.minor, self.loader, self.max_edge) + use_art_bytes(self.major, self.key, self.loader, self.max_edge) } } pub fn use_art_bytes( major: Option, - minor: Option, + key: Option, loader: Option, max_edge: u32, ) -> (String, Bytes) { let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); - let art_url = use_version_metadata(major, minor, loader).and_then(|m| m.art_url); + let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url); let image_query = use_cached_image(art_url.clone(), max_edge); diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index f30f513f..644d4644 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,4 +1,5 @@ use freya::query::QueryStateData; +use oneclient_core::VersionKey; use oneclient_core::VersionMetadata; use oneclient_core::packages::domain::GameLoader; @@ -7,7 +8,7 @@ use super::use_versions; pub fn pick_version_metadata( list: &[VersionMetadata], major: u32, - minor: Option, + key: Option, loader: Option, ) -> Option { let loader_matches = |m: &VersionMetadata| match (&m.loader, loader) { @@ -15,12 +16,20 @@ pub fn pick_version_metadata( _ => false, }; - if let Some(minor) = minor { - if let Some(hit) = list.iter().find(|m| { - m.major_version == major && m.minor_version == Some(minor) && loader_matches(m) - }) { + if let Some(key) = key { + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key) && loader_matches(m)) + { return Some(hit.clone()); } + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key)) + { + return Some(hit.clone()); + } + let (minor, _) = key; if let Some(hit) = list .iter() .find(|m| m.major_version == major && m.minor_version == Some(minor)) @@ -36,7 +45,7 @@ pub fn pick_version_metadata( pub fn use_version_metadata( major: Option, - minor: Option, + key: Option, loader: Option, ) -> Option { let versions_query = use_versions(); @@ -52,5 +61,59 @@ pub fn use_version_metadata( _ => return None, }; - pick_version_metadata(list, major, minor, loader) + pick_version_metadata(list, major, key, loader) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry( + major: u32, + minor: Option, + patch: Option, + loader: Option<&str>, + name: &str, + ) -> VersionMetadata { + VersionMetadata { + major_version: major, + minor_version: minor, + patch_version: patch, + loader: loader.map(str::to_string), + name: name.to_string(), + art_url: None, + long_description: None, + tags: Vec::new(), + } + } + + fn list() -> Vec { + vec![ + entry(26, None, None, None, "major"), + entry(26, Some(1), None, Some("fabric"), "bare minor"), + entry(26, Some(1), Some(2), Some("fabric"), "patched"), + ] + } + + #[test] + fn patch_and_bare_minor_are_distinct() { + let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric)); + assert_eq!(bare.unwrap().name, "bare minor"); + + let patched = + pick_version_metadata(&list(), 26, Some((1, Some(2))), Some(GameLoader::Fabric)); + assert_eq!(patched.unwrap().name, "patched"); + } + + #[test] + fn unknown_patch_falls_back_to_its_minor_line() { + let hit = pick_version_metadata(&list(), 26, Some((1, Some(9))), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "bare minor"); + } + + #[test] + fn unknown_minor_falls_back_to_major() { + let hit = pick_version_metadata(&list(), 26, Some((7, None)), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "major"); + } } diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 2572ac07..b870e958 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -123,7 +123,7 @@ impl Component for ClusterShell { let parsed = parse_mc_version(&cluster.mc_version); let metadata = use_version_metadata( parsed.as_ref().map(|p| p.major), - parsed.and_then(|p| p.minor), + parsed.and_then(|p| p.key()), Some(cluster.mc_loader), ); let subtitle = metadata diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index ab046e6e..91aca5c2 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -5,10 +5,14 @@ use std::sync::OnceLock; use chrono::{Datelike, NaiveDate}; use oneclient_core::clusters::Cluster; use oneclient_core::packages::domain::GameLoader; -use oneclient_core::{format_mc_version, parse_mc_version}; +use oneclient_core::{VersionKey, format_mc_version, parse_mc_version}; pub type ClusterGroups = BTreeMap>; +fn cluster_key(cluster: &Cluster) -> Option { + parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key()) +} + pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { let mut groups: ClusterGroups = BTreeMap::new(); @@ -23,7 +27,7 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { } for list in groups.values_mut() { - list.sort_by(|a, b| a.mc_version.cmp(&b.mc_version).reverse()); + list.sort_by(|a, b| cluster_key(b).cmp(&cluster_key(a))); } groups @@ -51,18 +55,17 @@ pub fn loader_tags(clusters: &[Cluster]) -> Vec { tags } -pub fn minor_versions(clusters: &[Cluster]) -> Vec { - let mut minors = Vec::new(); +pub fn version_keys(clusters: &[Cluster]) -> Vec { + let mut keys = Vec::new(); for cluster in clusters { - if let Some(parsed) = parse_mc_version(&cluster.mc_version) - && let Some(minor) = parsed.minor - && !minors.contains(&minor) + if let Some(key) = cluster_key(cluster) + && !keys.contains(&key) { - minors.push(minor); + keys.push(key); } } - minors.sort(); - minors + keys.sort(); + keys } pub fn loaders_for_major(clusters: &[Cluster]) -> Vec { @@ -78,15 +81,13 @@ pub fn loaders_for_major(clusters: &[Cluster]) -> Vec { pub fn resolve_cluster( clusters: &[Cluster], - minor: Option, + key: Option, loader: Option, ) -> Option { clusters .iter() .find(|cluster| { - let version_ok = minor.is_none_or(|minor| { - parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.minor) == Some(minor) - }); + let version_ok = key.is_none_or(|key| cluster_key(cluster) == Some(key)); let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); version_ok && loader_ok }) @@ -102,17 +103,20 @@ pub fn default_major(groups: &ClusterGroups, active: Option) -> Option< groups.keys().next().copied() } -pub fn default_minor(clusters: &[Cluster], preferred: Option) -> Option { - let minors = minor_versions(clusters); - if minors.is_empty() { +pub fn default_version_key( + clusters: &[Cluster], + preferred: Option, +) -> Option { + let keys = version_keys(clusters); + if keys.is_empty() { return None; } - if let Some(minor) = preferred - && minors.contains(&minor) + if let Some(key) = preferred + && keys.contains(&key) { - return Some(minor); + return Some(key); } - minors.last().copied() + keys.last().copied() } pub fn default_loader(clusters: &[Cluster], preferred: Option) -> Option { @@ -128,8 +132,8 @@ pub fn default_loader(clusters: &[Cluster], preferred: Option) -> Op Some(loaders[0]) } -pub fn minor_label(major: u32, minor: u32) -> String { - format_mc_version(major, minor) +pub fn version_label(major: u32, (minor, patch): VersionKey) -> String { + format_mc_version(major, minor, patch) } /// Human-readable byte size (B / KB / MB). @@ -236,10 +240,10 @@ fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { } } -fn version_sort_key(cluster: &Cluster) -> (u32, u32) { +fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) { parse_mc_version(&cluster.mc_version) - .map(|v| (v.major, v.minor.unwrap_or(0))) - .unwrap_or((0, 0)) + .map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0))) + .unwrap_or((0, 0, 0)) } #[cfg(not(target_os = "linux"))] diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 3d416fe9..b721bcc4 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use freya::query::QueryStateData; use freya::router::RouterContext; +use oneclient_core::VersionKey; use oneclient_core::clusters::Cluster; use oneclient_core::packages::domain::GameLoader; @@ -12,8 +13,8 @@ use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::{ - default_loader, default_major, default_minor, group_clusters_by_major, loaders_for_major, - major_pretty_name, minor_label, minor_versions, resolve_cluster, + default_loader, default_major, default_version_key, group_clusters_by_major, loaders_for_major, + major_pretty_name, resolve_cluster, version_keys, version_label, }; use crate::view::app::launch_button_state; @@ -31,7 +32,7 @@ impl Component for Clusters { let clusters_query = use_clusters(); let active_id = use_active_cluster_id(); let mut selected_major = use_state(|| None::); - let mut selected_minor = use_state(|| None::); + let mut selected_version = use_state(|| None::); let mut selected_loader = use_state(|| None::); let mut grid_columns = use_state(|| 2_usize); @@ -90,12 +91,12 @@ impl Component for Clusters { let clusters_for_major = groups.get(&major).cloned().unwrap_or_default(); - if selected_minor.read().is_none() { + if selected_version.read().is_none() { let preferred = active_cluster .as_ref() .and_then(|c| oneclient_core::parse_mc_version(&c.mc_version)) - .and_then(|p| p.minor); - *selected_minor.write() = default_minor(&clusters_for_major, preferred); + .and_then(|p| p.key()); + *selected_version.write() = default_version_key(&clusters_for_major, preferred); } if selected_loader.read().is_none() { @@ -105,7 +106,7 @@ impl Component for Clusters { let cluster = resolve_cluster( &clusters_for_major, - *selected_minor.read(), + *selected_version.read(), *selected_loader.read(), ) .or_else(|| clusters_for_major.first().cloned()); @@ -152,12 +153,12 @@ impl Component for Clusters { let list = groups.get(&major).cloned().unwrap_or_default(); let selected = *selected_major.read() == Some(major); let mut selected_major = selected_major; - let mut selected_minor = selected_minor; + let mut selected_version = selected_version; let mut selected_loader = selected_loader; MajorVersionCard::new(major, &list, selected, move |_| { *selected_major.write() = Some(major); - *selected_minor.write() = None; + *selected_version.write() = None; *selected_loader.write() = None; }) .into_element() @@ -176,7 +177,7 @@ impl Component for Clusters { major, cluster, &clusters_for_major, - selected_minor, + selected_version, selected_loader, ), None => sidebar_error(), @@ -189,20 +190,20 @@ fn detail_sidebar( major: u32, cluster: Cluster, clusters_for_major: &[Cluster], - selected_minor: State>, + selected_version: State>, selected_loader: State>, ) -> Element { let active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); - let minors = minor_versions(clusters_for_major); + let keys = version_keys(clusters_for_major); let loaders = loaders_for_major(clusters_for_major); let cluster_id = cluster.id; let version_title = major_pretty_name(major); - let minor_value = *selected_minor.read(); + let version_value = *selected_version.read(); let loader_value = *selected_loader.read(); - let metadata = use_version_metadata(Some(major), minor_value, loader_value); + let metadata = use_version_metadata(Some(major), version_value, loader_value); let heading = metadata .as_ref() @@ -229,7 +230,7 @@ fn detail_sidebar( .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( - ClusterLandscapeArt::for_version(major, minor_value, loader_value, false), + ClusterLandscapeArt::for_version(major, version_value, loader_value, false), )) .child( rect() @@ -259,7 +260,7 @@ fn detail_sidebar( .font_size(12.) .color(colors::fg_secondary()), ) - .children(minor_rows(major, &minors, minor_value, selected_minor)) + .children(version_rows(major, &keys, version_value, selected_version)) .children(loader_rows(&loaders, loader_value, selected_loader)), ) .child( @@ -308,28 +309,28 @@ fn tags_row(tags: &[String]) -> Option { ) } -fn minor_rows( +fn version_rows( major: u32, - minors: &[u32], - selected: Option, - mut selected_minor: State>, + keys: &[VersionKey], + selected: Option, + mut selected_version: State>, ) -> Option { - if minors.len() <= 1 { + if keys.len() <= 1 { return None; } - let options: Vec = minors.iter().map(|m| minor_label(major, *m)).collect(); + let options: Vec = keys.iter().map(|k| version_label(major, *k)).collect(); let current = selected - .and_then(|s| minors.iter().position(|m| *m == s)) + .and_then(|s| keys.iter().position(|k| *k == s)) .unwrap_or(0); - let minors = minors.to_vec(); + let keys = keys.to_vec(); Some(info_row( "Minor Version", Dropdown::new(options[current].clone(), options) .on_select(move |idx: usize| { - if let Some(minor) = minors.get(idx).copied() { - *selected_minor.write() = Some(minor); + if let Some(key) = keys.get(idx).copied() { + *selected_version.write() = Some(key); } }) .into_element(), diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs new file mode 100644 index 00000000..d7bbb787 --- /dev/null +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -0,0 +1,272 @@ +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao}; +use oneclient_db::models::ClusterRow; + +use crate::LauncherResult; +use crate::packages::domain::GameLoader; +use crate::state::LauncherState; +use crate::versions::RemoteMigration; + +#[tracing::instrument(skip(state))] +pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { + let rules = state.versions.migrations().await; + if rules.is_empty() { + return Ok(0); + } + + let mut migrated = 0; + + for rule in rules { + match apply_rule(state, &rule).await { + Ok(true) => migrated += 1, + Ok(false) => {} + Err(err) => { + tracing::warn!( + migration_id = %rule.id, + error = %err, + "cluster migration failed; leaving install untouched" + ); + } + } + } + + if migrated > 0 { + tracing::info!(migrated, "applied cluster migrations"); + } + + Ok(migrated) +} + +async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherResult { + let db = &state.services.db; + + if migration_dao::is_applied(db, &rule.id).await? { + return Ok(false); + } + + let Ok(loader) = GameLoader::from_str(&rule.from.loader) else { + tracing::warn!( + migration_id = %rule.id, + loader = %rule.from.loader, + "unknown loader in migration rule; retiring rule" + ); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if rule.from.mc_version == rule.to.mc_version { + tracing::warn!(migration_id = %rule.id, "migration is a no-op; retiring rule"); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + } + + let Some(source) = + cluster_dao::find_by_version_loader(db, &rule.from.mc_version, loader as i64).await? + else { + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if cluster_dao::find_by_version_loader(db, &rule.to.mc_version, loader as i64) + .await? + .is_some() + { + tracing::warn!( + migration_id = %rule.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "target cluster already exists; skipping migration" + ); + return Ok(false); + } + + if crate::game::is_running(state, source.id) { + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster is running; deferring migration" + ); + return Ok(false); + } + + migrate_cluster(state, rule, &source).await?; + + migration_dao::mark_applied(db, &rule.id).await?; + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "migrated cluster" + ); + + Ok(true) +} + +async fn migrate_cluster( + state: &LauncherState, + rule: &RemoteMigration, + source: &ClusterRow, +) -> LauncherResult<()> { + let from = &rule.from.mc_version; + let to = &rule.to.mc_version; + + let clusters_dir = crate::paths::clusters_dir()?; + let old_dir = clusters_dir.join(&source.folder_name); + + let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?; + let new_name = retarget_version_prefix(&source.name, from, to); + + let mut renamed: Option<(PathBuf, PathBuf)> = None; + if let Some(folder) = &new_folder { + let new_dir = clusters_dir.join(folder); + if polyio::try_exists(&old_dir).await? { + polyio::rename(&old_dir, &new_dir).await?; + renamed = Some((old_dir.clone(), new_dir)); + } else { + tracing::warn!( + folder = %source.folder_name, + "cluster directory is missing on disk; migrating the row only" + ); + } + } + + let folder_for_db = folder_for_db( + &source.folder_name, + new_folder.as_deref(), + renamed.is_some(), + ); + + match cluster_dao::migrate_version( + &state.services.db, + source.id, + to, + new_name.as_deref(), + folder_for_db, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => { + if let Some((old_dir, new_dir)) = renamed + && let Err(rollback) = polyio::rename(&new_dir, &old_dir).await + { + tracing::error!( + migration_id = %rule.id, + cluster_id = source.id, + from = ?new_dir, + to = ?old_dir, + error = %rollback, + "failed to roll back cluster directory rename after database error" + ); + } + Err(err.into()) + } + } +} + +fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str { + match (renamed, new_folder) { + (true, Some(folder)) => folder, + _ => current, + } +} + +async fn resolve_new_folder( + clusters_dir: &Path, + folder_name: &str, + from: &str, + to: &str, +) -> LauncherResult> { + let Some(candidate) = retarget_version_prefix(folder_name, from, to) else { + tracing::debug!( + folder = %folder_name, + "folder name is not in generated form; leaving it in place" + ); + return Ok(None); + }; + + if polyio::try_exists(&clusters_dir.join(&candidate)).await? { + tracing::warn!( + folder = %folder_name, + candidate = %candidate, + "target folder already exists; leaving directory in place" + ); + return Ok(None); + } + + Ok(Some(candidate)) +} + +fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option { + let rest = value.strip_prefix(from)?; + if !rest.starts_with(' ') { + return None; + } + Some(format!("{to}{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retargets_generated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric") + ); + } + + #[test] + fn retargets_deduplicated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric (1)", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric (1)") + ); + } + + #[test] + fn does_not_match_adjacent_minor() { + assert_eq!( + retarget_version_prefix("26.10 fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn leaves_custom_names_alone() { + assert_eq!( + retarget_version_prefix("my cool pack", "26.1", "26.1.2"), + None + ); + assert_eq!( + retarget_version_prefix("26.1fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn exact_version_with_no_suffix_is_not_retargeted() { + assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None); + } + + #[test] + fn row_claims_new_folder_only_when_the_directory_moved() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), true), + "26.1.2 fabric" + ); + } + + #[test] + fn row_keeps_its_folder_when_the_rename_was_skipped() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), false), + "26.1 fabric" + ); + assert_eq!(folder_for_db("my cool pack", None, false), "my cool pack"); + } +} diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 800a6a8f..29106145 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -1,6 +1,7 @@ mod cluster; mod error; mod manager; +mod migrate; mod options; mod prepare; mod provision; @@ -9,6 +10,7 @@ mod stage; pub use cluster::{Cluster, ClusterLinkTarget}; pub use error::ClusterError; pub use manager::ClusterManager; +pub use migrate::apply_remote_migrations; pub use options::{ClusterUpdate, CreateClusterOptions}; pub use prepare::{estimate_cluster_download, prepare_cluster}; pub use provision::{ensure_from_bundles, ensure_from_versions}; diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index c80b105f..898a3657 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -90,7 +90,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult, + pub patch: Option, } +impl ParsedMcVersion { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor?, self.patch)) + } +} + +pub type VersionKey = (u32, Option); + pub fn parse_mc_version(version: &str) -> Option { let parts: Vec<&str> = version.split('.').collect(); @@ -14,9 +24,14 @@ pub fn parse_mc_version(version: &str) -> Option { } let major = parts[0].parse().ok()?; let minor = parts[1].parse().ok()?; + let patch = match parts.get(2) { + Some(p) => Some(p.parse().ok()?), + None => None, + }; return Some(ParsedMcVersion { major, minor: Some(minor), + patch, }); } @@ -25,19 +40,26 @@ pub fn parse_mc_version(version: &str) -> Option { } let major = parts[1].parse().ok()?; - let minor = parts - .get(2) - .and_then(|p| p.parse().ok()); + let minor = parts.get(2).and_then(|p| p.parse().ok()); - Some(ParsedMcVersion { major, minor }) + Some(ParsedMcVersion { + major, + minor, + patch: None, + }) } #[must_use] -pub fn format_mc_version(major: u32, minor: u32) -> String { - if major >= 26 { +pub fn format_mc_version(major: u32, minor: u32, patch: Option) -> String { + let base = if major >= 26 { format!("{major}.{minor}") } else { format!("1.{major}.{minor}") + }; + + match patch { + Some(patch) => format!("{base}.{patch}"), + None => base, } } @@ -55,10 +77,20 @@ mod tests { let v = parse_mc_version("26.1").unwrap(); assert_eq!(v.major, 26); assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, None); let v = parse_mc_version("26.2").unwrap(); assert_eq!(v.major, 26); assert_eq!(v.minor, Some(2)); + assert_eq!(v.patch, None); + } + + #[test] + fn parse_modern_patch_versions() { + let v = parse_mc_version("26.1.2").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, Some(2)); } #[test] @@ -66,11 +98,36 @@ mod tests { let v = parse_mc_version("1.21.5").unwrap(); assert_eq!(v.major, 21); assert_eq!(v.minor, Some(5)); + assert_eq!(v.patch, None); + } + + #[test] + fn reject_garbage_patch_component() { + assert!(parse_mc_version("26.1.x").is_none()); } #[test] fn format_versions() { - assert_eq!(format_mc_version(26, 1), "26.1"); - assert_eq!(format_mc_version(21, 5), "1.21.5"); + assert_eq!(format_mc_version(26, 1, None), "26.1"); + assert_eq!(format_mc_version(26, 1, Some(2)), "26.1.2"); + assert_eq!(format_mc_version(21, 5, None), "1.21.5"); + } + + #[test] + fn format_round_trips_through_parse() { + for (major, minor, patch) in [(26, 1, None), (26, 1, Some(2)), (21, 5, None)] { + let text = format_mc_version(major, minor, patch); + let parsed = parse_mc_version(&text).unwrap(); + assert_eq!(parsed.major, major); + assert_eq!(parsed.minor, Some(minor)); + assert_eq!(parsed.patch, patch); + } + } + + #[test] + fn patch_sorts_after_bare_minor() { + let bare = parse_mc_version("26.1").unwrap().key().unwrap(); + let patched = parse_mc_version("26.1.2").unwrap().key().unwrap(); + assert!(bare < patched); } } diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index aabb5dbd..e8fe5f8b 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -7,7 +7,7 @@ use tokio::sync::RwLock; use crate::api_config::meta_url_base; use crate::paths; use crate::state::LauncherServices; -use crate::versions::manifest::{VersionMetadata, VersionsManifest}; +use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; use crate::{LauncherError, LauncherResult}; pub struct VersionsManager { @@ -36,6 +36,10 @@ impl VersionsManager { self.manifest.read().await.metadata() } + pub async fn migrations(&self) -> Vec { + self.manifest.read().await.migrations.clone() + } + #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index d3162290..2e0c8143 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -1,11 +1,14 @@ use serde::{Deserialize, Serialize}; use crate::api_config::meta_url_base; +use crate::version::VersionKey; #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub struct VersionsManifest { #[serde(default)] pub clusters: Vec, + #[serde(default)] + pub migrations: Vec, } #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -27,6 +30,8 @@ pub struct RemoteCluster { pub struct RemoteEntry { pub minor_version: u32, #[serde(default)] + pub patch_version: Option, + #[serde(default)] pub loader: Option, #[serde(default)] pub name: Option, @@ -38,10 +43,29 @@ pub struct RemoteEntry { pub tags: Option>, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteMigration { + pub id: String, + pub from: MigrationSource, + pub to: MigrationTarget, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationSource { + pub mc_version: String, + pub loader: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationTarget { + pub mc_version: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct VersionMetadata { pub major_version: u32, pub minor_version: Option, + pub patch_version: Option, pub loader: Option, pub name: String, pub art_url: Option, @@ -49,6 +73,13 @@ pub struct VersionMetadata { pub tags: Vec, } +impl VersionMetadata { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor_version?, self.patch_version)) + } +} + fn art_url(path: &Option) -> Option { path.as_ref() .map(|p| format!("{}{p}", meta_url_base())) @@ -67,6 +98,7 @@ impl VersionsManifest { out.push(VersionMetadata { major_version: cluster.major_version, minor_version: None, + patch_version: None, loader: None, name: cluster_name.clone(), art_url: art_url(&cluster.art), @@ -78,6 +110,7 @@ impl VersionsManifest { out.push(VersionMetadata { major_version: cluster.major_version, minor_version: Some(entry.minor_version), + patch_version: entry.patch_version, loader: entry.loader.clone(), name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), art_url: art_url(&entry.art).or_else(|| art_url(&cluster.art)), @@ -93,3 +126,57 @@ impl VersionsManifest { out } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_without_migrations_key_parses() { + let manifest: VersionsManifest = + serde_json::from_str(r#"{"clusters": []}"#).expect("should parse"); + assert!(manifest.migrations.is_empty()); + } + + #[test] + fn entry_without_patch_key_parses() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[{"minor_version":1}]}]}"#, + ) + .expect("should parse"); + assert_eq!(manifest.clusters[0].entries[0].patch_version, None); + } + + #[test] + fn patch_version_reaches_metadata() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":1,"patch_version":2,"loader":"fabric"} + ]}]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata(); + let entry = metadata + .iter() + .find(|m| m.minor_version == Some(1)) + .expect("entry present"); + assert_eq!(entry.patch_version, Some(2)); + assert_eq!(entry.key(), Some((1, Some(2)))); + } + + #[test] + fn migrations_parse() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[],"migrations":[ + {"id":"x","from":{"mc_version":"26.1","loader":"fabric"}, + "to":{"mc_version":"26.1.2"}} + ]}"#, + ) + .expect("should parse"); + + assert_eq!(manifest.migrations[0].id, "x"); + assert_eq!(manifest.migrations[0].from.mc_version, "26.1"); + assert_eq!(manifest.migrations[0].to.mc_version, "26.1.2"); + } +} diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs index 80c370f5..49b7cc27 100644 --- a/packages/oneclient_core/src/versions/mod.rs +++ b/packages/oneclient_core/src/versions/mod.rs @@ -2,4 +2,6 @@ mod manager; mod manifest; pub use manager::VersionsManager; -pub use manifest::{VersionMetadata, VersionsManifest}; +pub use manifest::{ + MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest, +}; diff --git a/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json new file mode 100644 index 00000000..8a0fed68 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM applied_migrations WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "applied_migrations", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab" +} diff --git a/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json new file mode 100644 index 00000000..e2104f28 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61" +} diff --git a/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json new file mode 100644 index 00000000..b445c9cb --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c" +} diff --git a/packages/oneclient_db/.sqlx/query-dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2.json b/packages/oneclient_db/.sqlx/query-dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2.json new file mode 100644 index 00000000..2fe3f6e0 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT 1 FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name = ? AND package_id = ?\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "1", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false + ] + }, + "hash": "dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2" +} diff --git a/packages/oneclient_db/Cargo.toml b/packages/oneclient_db/Cargo.toml index b85c98ab..21edaa66 100644 --- a/packages/oneclient_db/Cargo.toml +++ b/packages/oneclient_db/Cargo.toml @@ -13,6 +13,9 @@ authors.workspace = true [lib] path = "src/lib.rs" +[dev-dependencies] +tokio.workspace = true + [dependencies] sqlx.workspace = true serde.workspace = true diff --git a/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql new file mode 100644 index 00000000..8fe785eb --- /dev/null +++ b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql @@ -0,0 +1,4 @@ +CREATE TABLE applied_migrations ( + id TEXT PRIMARY KEY NOT NULL, + applied_at TEXT NOT NULL +); diff --git a/packages/oneclient_db/src/dao/applied_migration.rs b/packages/oneclient_db/src/dao/applied_migration.rs new file mode 100644 index 00000000..27a205f1 --- /dev/null +++ b/packages/oneclient_db/src/dao/applied_migration.rs @@ -0,0 +1,24 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result { + let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + let applied_at = Utc::now().to_rfc3339(); + + sqlx::query!( + "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + id, + applied_at + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs index b418bf8e..48d770e0 100644 --- a/packages/oneclient_db/src/dao/cluster.rs +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -149,6 +149,40 @@ pub async fn update( .await } +pub async fn migrate_version( + pool: &SqlitePool, + id: i64, + mc_version: &str, + name: Option<&str>, + folder_name: &str, +) -> Result { + let existing = get_by_id(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + let name = name.unwrap_or(&existing.name); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET mc_version = ?, + name = ?, + folder_name = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + mc_version, + name, + folder_name, + id + ) + .fetch_one(pool) + .await +} + pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result { sqlx::query_as!( ClusterRow, @@ -200,3 +234,102 @@ pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result 0) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dao::applied_migration; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } + + async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { + insert( + pool, + &NewCluster { + name: &format!("{mc_version} fabric"), + folder_name: folder, + mc_version, + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + }, + ) + .await + .expect("insert cluster") + } + + #[tokio::test] + async fn migrate_version_rewrites_identity_and_keeps_history() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "26.1 fabric").await; + add_playtime(&pool, cluster.id, 3600) + .await + .expect("record playtime"); + + let migrated = migrate_version( + &pool, + cluster.id, + "26.1.2", + Some("26.1.2 fabric"), + "26.1.2 fabric", + ) + .await + .expect("migrate"); + + assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.folder_name, "26.1.2 fabric"); + assert_eq!(migrated.name, "26.1.2 fabric"); + assert_eq!(migrated.overall_played, Some(3600)); + assert_eq!(migrated.created_at, cluster.created_at); + + assert!( + find_by_version_loader(&pool, "26.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "26.1.2", 1) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } + + #[tokio::test] + async fn migrate_version_can_leave_name_and_folder_untouched() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "my cool pack").await; + + let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") + .await + .expect("migrate"); + + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); + assert_eq!(migrated.folder_name, "my cool pack"); + } + + #[tokio::test] + async fn applied_migrations_ledger_is_idempotent() { + let pool = pool().await; + + assert!(!applied_migration::is_applied(&pool, "rule-a").await.unwrap()); + applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); + assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); + + applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); + assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); + assert!(!applied_migration::is_applied(&pool, "rule-b").await.unwrap()); + } +} diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs index df650306..986fee8f 100644 --- a/packages/oneclient_db/src/dao/mod.rs +++ b/packages/oneclient_db/src/dao/mod.rs @@ -1,3 +1,4 @@ +pub mod applied_migration; pub mod artifact; pub mod bundle; pub mod cluster;