diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index a6c759f1..97c8055a 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -129,6 +129,7 @@ fn entry_from_item( hidden: false, opted_in: true, shadowed: false, + outranked: false, advanced: false, update_available: false, // Offered, not installed, so there is no recency badge to show diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index bbc646e2..d5a37fed 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -66,6 +66,8 @@ pub struct PackageEntry { /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, pub shadowed: bool, + /// The user's own copy, older than the bundle's the loader picks over it + pub outranked: bool, /// Recency badge state cleared once the user views the list pub seen_status: SeenStatus, pub essential: Option<&'static EssentialPackage>, @@ -470,7 +472,8 @@ pub(crate) fn grid_card( .into_element() }); - let badged = item.is_outdated() || item.shadowed || item.recency_badge().is_some(); + let badged = + item.is_outdated() || item.shadowed || item.outranked || item.recency_badge().is_some(); let floating = badged.then(|| { rect() .horizontal() @@ -485,6 +488,7 @@ pub(crate) fn grid_card( .spacing(4.) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.shadowed.then(shadowed_badge)) + .maybe_child(item.outranked.then(outranked_badge)) .maybe_child(item.recency_badge()) .into_element() }); @@ -771,6 +775,7 @@ fn package_info( }) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.shadowed.then(shadowed_badge)) + .maybe_child(item.outranked.then(outranked_badge)) .maybe_child(item.recency_badge()), ) .maybe(!item.author.is_empty(), |el| { @@ -876,6 +881,10 @@ fn shadowed_badge() -> Element { status_tag("Using your copy", colors::brand()) } +fn outranked_badge() -> Element { + status_tag("Using bundle's newer copy", colors::brand()) +} + fn new_badge() -> Element { status_tag("New", colors::success()) } diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index a3d40eeb..6c448951 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use oneclient_content::packages::release_migration::has_packages_to_migrate; use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_core::game::DuplicateMods; use oneclient_core::{LauncherError, LinkedArtifactInfo}; use oneclient_db::models::ClusterId; @@ -96,7 +97,7 @@ pub fn use_migratable_routes( pub struct ShadowedModsQuery; impl QueryCapability for ShadowedModsQuery { - type Ok = Arc>; + type Ok = Arc; type Err = LauncherError; type Keys = ClusterId; @@ -110,7 +111,7 @@ impl QueryCapability for ShadowedModsQuery { } } -pub fn use_shadowed_mods(cluster_id: ClusterId) -> Arc> { +pub fn use_shadowed_mods(cluster_id: ClusterId) -> Arc { let query = use_query(Query::new(cluster_id, ShadowedModsQuery)); super::state::settled_or_loading(&query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index b27ca276..c28950cd 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -47,10 +47,11 @@ impl Component for ClusterMods { ContentType::Mod, ); for item in &mut items { - item.shadowed = item - .hash - .as_ref() - .is_some_and(|hash| shadowed.contains(hash)); + let Some(hash) = &item.hash else { + continue; + }; + item.shadowed = shadowed.shadowed.contains(hash); + item.outranked = shadowed.outranked.contains(hash); } cluster_content() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index a573192f..b2606897 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -317,6 +317,7 @@ fn make_row( hidden, opted_in, shadowed: false, + outranked: false, advanced: false, seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(), } diff --git a/packages/oneclient_content/src/packages/fabric_version.rs b/packages/oneclient_content/src/packages/fabric_version.rs new file mode 100644 index 00000000..1c8cb67b --- /dev/null +++ b/packages/oneclient_content/src/packages/fabric_version.rs @@ -0,0 +1,167 @@ +//! Version ordering as Fabric Loader does it, ported from its `VersionParser`, + +use std::cmp::Ordering; + +struct Semantic<'a> { + components: Vec, + prerelease: Option<&'a str>, + build: Option<&'a str>, +} + +impl Semantic<'_> { + fn friendly(&self) -> String { + let mut out = self + .components + .iter() + .map(ToString::to_string) + .collect::>() + .join("."); + if let Some(prerelease) = self.prerelease { + out.push('-'); + out.push_str(prerelease); + } + if let Some(build) = self.build { + out.push('+'); + out.push_str(build); + } + out + } +} + +fn parse(version: &str) -> Option> { + let (version, build) = match version.split_once('+') { + Some((version, build)) => (version, Some(build)), + None => (version, None), + }; + let (version, prerelease) = match version.split_once('-') { + Some((version, prerelease)) => (version, Some(prerelease)), + None => (version, None), + }; + + if prerelease.is_some_and(|prerelease| !is_dot_separated_id(prerelease)) { + return None; + } + + let components = version + .split('.') + .map(|component| component.parse::().ok()) + .collect::>>()?; + + Some(Semantic { + components, + prerelease, + build, + }) +} + +fn is_dot_separated_id(text: &str) -> bool { + text.is_empty() + || text.split('.').all(|part| { + !part.is_empty() && part.bytes().all(|b| b == b'-' || b.is_ascii_alphanumeric()) + }) +} + +fn is_unsigned_integer(text: &str) -> bool { + text == "0" + || (!text.is_empty() && !text.starts_with('0') && text.bytes().all(|b| b.is_ascii_digit())) +} + +fn compare_strings(left: &str, right: &str) -> Ordering { + left.encode_utf16().cmp(right.encode_utf16()) +} + +fn compare_prereleases(left: &str, right: &str) -> Ordering { + let mut left = left.split('.').filter(|part| !part.is_empty()); + let mut right = right.split('.').filter(|part| !part.is_empty()); + + loop { + let (a, b) = match (left.next(), right.next()) { + (Some(a), Some(b)) => (a, b), + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => return Ordering::Equal, + }; + + let by_kind = match (is_unsigned_integer(a), is_unsigned_integer(b)) { + (true, true) => a.len().cmp(&b.len()), + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => Ordering::Equal, + }; + let ordering = by_kind.then_with(|| compare_strings(a, b)); + if ordering != Ordering::Equal { + return ordering; + } + } +} + +#[must_use] +pub fn compare(left: &str, right: &str) -> Ordering { + let (left, right) = match (parse(left), parse(right)) { + (Some(left), Some(right)) => (left, right), + (parsed_left, parsed_right) => { + let friendly = |parsed: Option>, raw: &str| { + parsed.map_or_else(|| raw.to_owned(), |semantic| semantic.friendly()) + }; + return compare_strings(&friendly(parsed_left, left), &friendly(parsed_right, right)); + } + }; + + for index in 0..left.components.len().max(right.components.len()) { + let component = + |semantic: &Semantic<'_>| semantic.components.get(index).copied().unwrap_or(0); + let ordering = component(&left).cmp(&component(&right)); + if ordering != Ordering::Equal { + return ordering; + } + } + + match (left.prerelease, right.prerelease) { + (Some(left), Some(right)) => compare_prereleases(left, right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_order_as_fabric_loader_orders_them() { + use Ordering::{Equal, Greater, Less}; + + for (left, right, expected) in [ + ("0.141.4+1.21.11", "0.141.6+1.21.11", Less), + ("1.13.12+kotlin.2.4.0", "1.14.1+kotlin.2.4.20", Less), + ("0.141.10", "0.141.9", Greater), + ("1.0", "1.0.0", Equal), + ("1.0.0+a", "1.0.0+b", Equal), + ("1.0.0-beta.1", "1.0.0", Less), + ("1.0.0-beta.2", "1.0.0-beta.10", Less), + ("1.0.0-beta", "1.0.0-beta.1", Less), + ("1.0.0-1", "1.0.0-alpha", Less), + ( + "0.4.0-alpha.0.27+1.21.11", + "0.4.0-alpha.0.3+1.21.11", + Greater, + ), + ("1.01", "1.1", Equal), + ("1.0.0-", "1.0.0", Less), + ("1.0.0-", "1.0.0-0", Less), + ("1.0.0-rc", "1.0.0-rc-1", Less), + ("1.99999999999", "1.200", Greater), + ("1..5", "1.0.5", Less), + ("v1.10", "v1.9", Less), + ("1.2", "1.2a", Less), + ] { + assert_eq!(compare(left, right), expected, "{left} vs {right}"); + assert_eq!( + compare(right, left), + expected.reverse(), + "{right} vs {left}" + ); + } + } +} diff --git a/packages/oneclient_content/src/packages/local_manifest.rs b/packages/oneclient_content/src/packages/local_manifest.rs index f9803b10..6c76e6c3 100644 --- a/packages/oneclient_content/src/packages/local_manifest.rs +++ b/packages/oneclient_content/src/packages/local_manifest.rs @@ -88,8 +88,14 @@ pub async fn read_jar_manifest(jar: &Path) -> JarManifest { out } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JarModId { + pub id: String, + pub version: Option, +} + #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] -pub async fn read_jar_mod_id(jar: &Path) -> Option { +pub async fn read_jar_mod_id(jar: &Path) -> Option { let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) .await .inspect_err(|err| tracing::debug!("could not read {}: {err}", jar.display())) @@ -97,19 +103,22 @@ pub async fn read_jar_mod_id(jar: &Path) -> Option { MANIFESTS.into_iter().find_map(|wanted| { let (_, bytes) = entries.iter().find(|(name, _)| name == wanted)?; - parse_mod_id(wanted, std::str::from_utf8(bytes).ok()?) + parse_mod(wanted, std::str::from_utf8(bytes).ok()?) }) } -fn parse_mod_id(manifest: &str, text: &str) -> Option { - let id = match manifest { - NEOFORGE | FORGE => toml::from_str::(text) - .ok()? - .get("mods")? - .get(0)? - .get("modId")? - .as_str()? - .to_owned(), +fn parse_mod(manifest: &str, text: &str) -> Option { + let (id, version) = match manifest { + NEOFORGE | FORGE => { + let id = toml::from_str::(text) + .ok()? + .get("mods")? + .get(0)? + .get("modId")? + .as_str()? + .to_owned(); + (id, None) + } _ => { let json: serde_json::Value = serde_json::from_str(text).ok()?; let pointers: &[&str] = match manifest { @@ -117,14 +126,21 @@ fn parse_mod_id(manifest: &str, text: &str) -> Option { QUILT => &["/quilt_loader/id"], _ => &["/0/modid", "/modList/0/modid"], }; - pointers + let id = pointers .iter() .find_map(|pointer| json.pointer(pointer)?.as_str())? - .to_owned() + .to_owned(); + let version = (manifest == FABRIC) + .then(|| json.pointer("/version")?.as_str().map(str::to_owned)) + .flatten(); + (id, version) } }; - clean(Some(id)).filter(|id| !is_placeholder_id(id)) + Some(JarModId { + id: clean(Some(id)).filter(|id| !is_placeholder_id(id))?, + version: clean(version).filter(|version| !version.contains('$')), + }) } fn is_placeholder_id(id: &str) -> bool { @@ -412,6 +428,14 @@ fn entry_path(raw: String) -> Option { mod tests { use super::*; + fn parse_mod_id(manifest: &str, text: &str) -> Option { + parse_mod(manifest, text).map(|parsed| parsed.id) + } + + fn parse_mod_version(manifest: &str, text: &str) -> Option { + parse_mod(manifest, text)?.version + } + #[test] fn every_loader_names_its_mod_id() { assert_eq!( @@ -435,6 +459,24 @@ mod tests { Some("oneconfig") ); assert_eq!(parse_mod_id(FABRIC, r#"{"id":" "}"#), None); + + assert_eq!( + parse_mod_version(FABRIC, r#"{"id":"fabric-api","version":"0.141.6+1.21.11"}"#) + .as_deref(), + Some("0.141.6+1.21.11") + ); + assert_eq!( + parse_mod_version(QUILT, r#"{"quilt_loader":{"id":"qsl","version":"1.2.3"}}"#), + None + ); + assert_eq!( + parse_mod_version(FABRIC, r#"{"id":"a","version":"${version}"}"#), + None + ); + assert_eq!( + parse_mod_version(FORGE, "[[mods]]\nmodId = \"jei\"\nversion = \"1\"\n"), + None + ); assert_eq!( parse_mod_id(LEGACY_FORGE, r#"[{"modid":"examplemod"}]"#), None diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 193d9629..828459d4 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -2,6 +2,7 @@ pub mod activity; pub mod bad_mods; pub mod dependencies; pub mod error; +pub mod fabric_version; pub mod local_manifest; pub mod metadata_cache; pub mod provider; diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 4e0685d4..fb504750 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -39,6 +39,7 @@ pub use process::{ }; pub use reattach::recover_sessions; pub use shared_dir::{ - dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, - shadowed_bundle_mods, sync_cluster_mods, unlink_cluster_logs, write_allowed_symlinks, + DuplicateMods, dematerialize_content, import_manual_content, link_cluster_logs, + materialize_content, shadowed_bundle_mods, sync_cluster_mods, unlink_cluster_logs, + write_allowed_symlinks, }; diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index a9b69a05..c372adac 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -11,9 +11,11 @@ use crate::LauncherResult; use crate::clusters::Cluster; use crate::state::{LauncherServices, LauncherState}; use oneclient_cluster::remove_mods_link; -use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; use oneclient_content::packages::PackageStore; +use oneclient_content::packages::fabric_version; +use oneclient_content::packages::local_manifest::JarModId; use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; use oneclient_content::packages::store::{ artifact_absolute_path, link_or_copy, remove_entry, sweep_staging_files, @@ -1204,7 +1206,14 @@ async fn skip_shadowed_bundle_mods( .filter(|item| item.content_type == ContentType::Mod) .map(|item| (item.hash.clone(), item.src.clone())) .collect(); - let shadowed = shadowed_bundle_hashes(services, cluster.id, &mods).await; + let shadowed = shadowed_bundle_hashes( + services, + cluster.id, + cluster.mc_loader == GameLoader::Fabric, + &mods, + ) + .await + .shadowed; if shadowed.is_empty() { return desired; } @@ -1254,11 +1263,11 @@ async fn skip_shadowed_bundle_mods( desired } -pub async fn shadowed_bundle_mods(state: &LauncherState, cluster_id: i64) -> HashSet { +pub async fn shadowed_bundle_mods(state: &LauncherState, cluster_id: i64) -> DuplicateMods { let services = &state.services; let Ok(linked) = PackageStore::list_linked_artifacts(cluster_id, &services.content()).await else { - return HashSet::new(); + return DuplicateMods::default(); }; let mut mods = Vec::new(); @@ -1274,25 +1283,32 @@ pub async fn shadowed_bundle_mods(state: &LauncherState, cluster_id: i64) -> Has } } - shadowed_bundle_hashes(services, cluster_id, &mods).await + let loader_picks_newer = cluster_dao::get_by_id(&services.db, cluster_id) + .await + .ok() + .flatten() + .is_some_and(|row| row.mc_loader == GameLoader::Fabric as i64); + + shadowed_bundle_hashes(services, cluster_id, loader_picks_newer, &mods).await } async fn shadowed_bundle_hashes( services: &LauncherServices, cluster_id: i64, + loader_picks_newer: bool, mods: &[(String, PathBuf)], -) -> HashSet { +) -> DuplicateMods { let bundled: HashSet = match bundle_dao::list_bundle_tracked(&services.db, cluster_id) .await { Ok(rows) => rows.into_iter().map(|row| row.hash).collect(), Err(err) => { tracing::warn!(error = %err, "cannot list bundle mods; not checking for duplicates"); - return HashSet::new(); + return DuplicateMods::default(); } }; if bundled.is_empty() { - return HashSet::new(); + return DuplicateMods::default(); } let mut copies = Vec::new(); @@ -1310,15 +1326,15 @@ async fn shadowed_bundle_hashes( ); if !bundle_pass && copies.iter().all(|copy| copy.mod_id.is_none()) { - return HashSet::new(); + return DuplicateMods::default(); } } - shadowed_bundle_copies(&copies) + shadowed_bundle_copies(&copies, loader_picks_newer) } -async fn cached_mod_id(hash: &str, jar: &Path) -> Option { - static IDS: std::sync::Mutex>> = +async fn cached_mod_id(hash: &str, jar: &Path) -> Option { + static IDS: std::sync::Mutex>> = std::sync::Mutex::new(std::collections::BTreeMap::new()); if let Some(id) = IDS.lock().unwrap().get(hash) { @@ -1335,21 +1351,49 @@ async fn cached_mod_id(hash: &str, jar: &Path) -> Option { struct ModCopy { hash: String, bundled: bool, - mod_id: Option, + mod_id: Option, } -fn shadowed_bundle_copies(mods: &[ModCopy]) -> HashSet { - let users: HashSet<&str> = mods - .iter() - .filter(|copy| !copy.bundled) - .filter_map(|copy| copy.mod_id.as_deref()) - .collect(); +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DuplicateMods { + pub shadowed: HashSet, + pub outranked: HashSet, +} - mods.iter() - .filter(|copy| copy.bundled) - .filter(|copy| copy.mod_id.as_deref().is_some_and(|id| users.contains(id))) - .map(|copy| copy.hash.clone()) - .collect() +fn shadowed_bundle_copies(mods: &[ModCopy], loader_picks_newer: bool) -> DuplicateMods { + let mut duplicates = DuplicateMods::default(); + + for copy in mods.iter().filter(|copy| copy.bundled) { + let Some(bundle) = ©.mod_id else { + continue; + }; + let users: Vec<(&str, &JarModId)> = mods + .iter() + .filter(|copy| !copy.bundled) + .filter_map(|copy| Some((copy.hash.as_str(), copy.mod_id.as_ref()?))) + .filter(|(_, user)| user.id == bundle.id) + .collect(); + if users.is_empty() { + continue; + } + + if loader_picks_newer && users.iter().all(|(_, user)| is_older(user, bundle)) { + duplicates + .outranked + .extend(users.iter().map(|(hash, _)| (*hash).to_owned())); + } else { + duplicates.shadowed.insert(copy.hash.clone()); + } + } + + duplicates +} + +fn is_older(user: &JarModId, bundle: &JarModId) -> bool { + match (&user.version, &bundle.version) { + (Some(user), Some(bundle)) => fabric_version::compare(user, bundle).is_lt(), + _ => false, + } } async fn cached_file( @@ -2869,21 +2913,67 @@ mod tests { #[test] fn a_bundle_mod_the_user_added_their_own_copy_of_is_skipped() { - let copy = |hash: &str, bundled: bool, mod_id: Option<&str>| ModCopy { + let versioned = |hash: &str, bundled: bool, id: &str, version: Option<&str>| ModCopy { hash: hash.into(), bundled, - mod_id: mod_id.map(Into::into), + mod_id: Some(JarModId { + id: id.into(), + version: version.map(Into::into), + }), + }; + let copy = |hash: &str, bundled: bool, mod_id: Option<&str>| match mod_id { + Some(id) => versioned(hash, bundled, id, None), + None => ModCopy { + hash: hash.into(), + bundled, + mod_id: None, + }, }; - let shadowed = shadowed_bundle_copies(&[ - copy("bundle-argentum", true, Some("argentum")), - copy("user-argentum", false, Some("argentum")), - copy("bundle-sodium", true, Some("sodium")), - copy("bundle-unreadable", true, None), - copy("user-unreadable", false, None), - ]); + let duplicates = shadowed_bundle_copies( + &[ + copy("bundle-argentum", true, Some("argentum")), + copy("user-argentum", false, Some("argentum")), + copy("bundle-sodium", true, Some("sodium")), + copy("bundle-unreadable", true, None), + copy("user-unreadable", false, None), + ], + true, + ); - assert_eq!(shadowed, HashSet::from(["bundle-argentum".to_owned()])); + assert_eq!( + duplicates.shadowed, + HashSet::from(["bundle-argentum".to_owned()]) + ); + assert!(duplicates.outranked.is_empty()); + + let versioned_copies = [ + versioned("bundle-api", true, "fabric-api", Some("0.141.6+1.21.11")), + versioned("user-api", false, "fabric-api", Some("0.141.4+1.21.11")), + versioned("bundle-sodium", true, "sodium", Some("0.8.3")), + versioned("user-sodium", false, "sodium", Some("0.8.5")), + versioned("bundle-iris", true, "iris", Some("1.9.0")), + versioned("user-iris", false, "iris", Some("1.9.0")), + ]; + let duplicates = shadowed_bundle_copies(&versioned_copies, true); + + assert_eq!( + duplicates.shadowed, + HashSet::from(["bundle-sodium".to_owned(), "bundle-iris".to_owned()]) + ); + assert_eq!(duplicates.outranked, HashSet::from(["user-api".to_owned()])); + + let duplicates = shadowed_bundle_copies(&versioned_copies, false); + + assert_eq!( + duplicates.shadowed, + HashSet::from([ + "bundle-api".to_owned(), + "bundle-sodium".to_owned(), + "bundle-iris".to_owned() + ]) + ); + assert!(duplicates.outranked.is_empty()); } #[test] diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 44462dc2..2f597408 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -82,6 +82,5 @@ pub use tos::{TermsDocument, fetch_terms}; pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use versions::{ ReleaseTarget, RemoteMigration, VersionArts, VersionMetadata, VersionsManager, - VersionsManifest, - resolve_migration_chain, + VersionsManifest, resolve_migration_chain, };