feat(labrinth): rework v3 side types to a single environment field (#3701)

* feat(labrinth): rework v3 side types to a single `environment` field

This field is meant to be able to represent the existing v2 side type
information and beyond, in a way that may also be slightly easier to
comprehend.

* chore(labrinth/migrations): use proper val for `HAVING` clause

* feat(labrinth): add `side_types_migration_review_status` field to projects
This commit is contained in:
Alejandro González
2025-06-17 00:44:57 +02:00
committed by GitHub
parent 65126b3a23
commit ef04dcc37b
22 changed files with 358 additions and 205 deletions

View File

@@ -6,7 +6,9 @@ use super::{DBUser, ids::*};
use crate::database::models;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::projects::{MonetizationStatus, ProjectStatus};
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
};
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -210,6 +212,8 @@ impl ProjectBuilder {
webhook_sent: false,
color: self.color,
monetization_status: self.monetization_status,
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
};
project_struct.insert(&mut *transaction).await?;
@@ -288,6 +292,7 @@ pub struct DBProject {
pub webhook_sent: bool,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
}
@@ -302,13 +307,15 @@ impl DBProject {
id, team_id, name, summary, description,
published, downloads, icon_url, raw_icon_url, status, requested_status,
license_url, license,
slug, color, monetization_status, organization_id
slug, color, monetization_status, organization_id,
side_types_migration_review_status
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17
LOWER($14), $15, $16, $17,
$18
)
",
self.id as DBProjectId,
@@ -328,6 +335,7 @@ impl DBProject {
self.color.map(|x| x as i32),
self.monetization_status.as_str(),
self.organization_id.map(|x| x.0 as i64),
self.side_types_migration_review_status.as_str()
)
.execute(&mut **transaction)
.await?;
@@ -770,6 +778,7 @@ impl DBProject {
m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
m.webhook_sent, m.color,
t.id thread_id, m.monetization_status monetization_status,
m.side_types_migration_review_status side_types_migration_review_status,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
FROM mods m
@@ -835,6 +844,9 @@ impl DBProject {
monetization_status: MonetizationStatus::from_string(
&m.monetization_status,
),
side_types_migration_review_status: SideTypesMigrationReviewStatus::from_string(
&m.side_types_migration_review_status,
),
loaders,
},
categories: m.categories.unwrap_or_default(),

View File

@@ -127,7 +127,7 @@ impl LegacyProject {
.collect();
if let Some(versions_item) = versions_item {
// Extract side types from remaining fields (singleplayer, client_only, etc)
// Extract side types from remaining fields
let fields = versions_item
.version_fields
.iter()
@@ -135,10 +135,11 @@ impl LegacyProject {
(f.field_name.clone(), f.value.clone().serialize_internal())
})
.collect::<HashMap<_, _>>();
(client_side, server_side) = v2_reroute::convert_side_types_v2(
&fields,
Some(&*og_project_type),
);
(client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&fields,
Some(&*og_project_type),
);
// - if loader is mrpack, this is a modpack
// the loaders are whatever the corresponding loader fields are

View File

@@ -102,28 +102,20 @@ impl LegacyResultSearchProject {
let project_loader_fields =
result_search_project.project_loader_fields.clone();
let get_one_bool_loader_field = |key: &str| {
let get_one_string_loader_field = |key: &str| {
project_loader_fields
.get(key)
.cloned()
.unwrap_or_default()
.map_or(&[][..], |values| values.as_slice())
.first()
.and_then(|s| s.as_bool())
.and_then(|s| s.as_str())
};
let singleplayer = get_one_bool_loader_field("singleplayer");
let client_only =
get_one_bool_loader_field("client_only").unwrap_or(false);
let server_only =
get_one_bool_loader_field("server_only").unwrap_or(false);
let client_and_server = get_one_bool_loader_field("client_and_server");
let environment =
get_one_string_loader_field("environment").unwrap_or("unknown");
let (client_side, server_side) =
v2_reroute::convert_side_types_v2_bools(
singleplayer,
client_only,
server_only,
client_and_server,
v2_reroute::convert_v3_environment_to_v2_side_types(
environment,
Some(&*og_project_type),
);
let client_side = client_side.to_string();

View File

@@ -92,6 +92,9 @@ pub struct Project {
/// The monetization status of this project
pub monetization_status: MonetizationStatus,
/// The status of the manual review of the migration of side types of this project
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
/// Aggregated loader-fields across its myriad of versions
#[serde(flatten)]
pub fields: HashMap<String, Vec<serde_json::Value>>,
@@ -206,6 +209,8 @@ impl From<ProjectQueryResult> for Project {
color: m.color,
thread_id: data.thread_id.into(),
monetization_status: m.monetization_status,
side_types_migration_review_status: m
.side_types_migration_review_status,
fields,
}
}
@@ -588,6 +593,35 @@ impl MonetizationStatus {
}
}
/// Represents the status of the manual review of the migration of side types of this
/// project to the new environment field.
#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum SideTypesMigrationReviewStatus {
/// The project has been reviewed to use the new environment side types appropriately.
Reviewed,
/// The project has been automatically migrated to the new environment side types, but
/// the appropriateness of such migration has not been reviewed.
Pending,
}
impl SideTypesMigrationReviewStatus {
pub fn as_str(&self) -> &'static str {
match self {
SideTypesMigrationReviewStatus::Reviewed => "reviewed",
SideTypesMigrationReviewStatus::Pending => "pending",
}
}
pub fn from_string(string: &str) -> SideTypesMigrationReviewStatus {
match string {
"reviewed" => SideTypesMigrationReviewStatus::Reviewed,
"pending" => SideTypesMigrationReviewStatus::Pending,
_ => SideTypesMigrationReviewStatus::Reviewed,
}
}
}
/// A specific version of a project
#[derive(Serialize, Deserialize, Clone)]
pub struct Version {
@@ -846,7 +880,6 @@ impl std::fmt::Display for VersionType {
}
impl VersionType {
// These are constant, so this can remove unneccessary allocations (`to_string`)
pub fn as_str(&self) -> &'static str {
match self {
VersionType::Release => "release",

View File

@@ -244,7 +244,7 @@ impl AutomatedModerationQueue {
version_specific: HashMap::new(),
};
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| ["server_only", "client_only", "client_and_server", "singleplayer"].contains(&&*x.field_name)) {
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") {
mod_messages.messages.push(ModerationMessage::NoSideTypes);
}

View File

@@ -158,10 +158,12 @@ pub async fn project_create(
.into_iter()
.map(|v| {
let mut fields = HashMap::new();
fields.extend(v2_reroute::convert_side_types_v3(
client_side,
server_side,
));
fields.extend(
v2_reroute::convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
),
);
fields.insert(
"game_versions".to_string(),
json!(v.game_versions),

View File

@@ -511,6 +511,7 @@ pub async fn project_edit(
moderation_message: v2_new_project.moderation_message,
moderation_message_body: v2_new_project.moderation_message_body,
monetization_status: v2_new_project.monetization_status,
side_types_migration_review_status: None, // Not to be exposed in v2
};
// This returns 204 or failure so we don't need to do anything with it
@@ -547,10 +548,12 @@ pub async fn project_edit(
let version = Version::from(version);
let mut fields = version.fields;
let (current_client_side, current_server_side) =
v2_reroute::convert_side_types_v2(&fields, None);
v2_reroute::convert_v3_side_types_to_v2_side_types(
&fields, None,
);
let client_side = client_side.unwrap_or(current_client_side);
let server_side = server_side.unwrap_or(current_server_side);
fields.extend(v2_reroute::convert_side_types_v3(
fields.extend(v2_reroute::convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
));

View File

@@ -105,7 +105,7 @@ pub async fn version_create(
json!(legacy_create.game_versions),
);
// Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc.
// Get all possible side-types for loaders given- we will use these to check if we need to convert/apply side types
let loaders =
match v3::tags::loader_list(client.clone(), redis.clone())
.await
@@ -136,53 +136,32 @@ pub async fn version_create(
.collect::<Vec<_>>();
// Copies side types of another version of the project.
// If no version exists, defaults to all false.
// If no version exists, defaults to an unknown side type.
// This is inherently lossy, but not much can be done about it, as side types are no longer associated with projects,
// so the 'missing' ones can't be easily accessed, and versions do need to have these fields explicitly set.
let side_type_loader_field_names = [
"singleplayer",
"client_and_server",
"client_only",
"server_only",
];
// so the 'missing' ones can't be easily accessed, and versions do need to have that field explicitly set.
// Check if loader_fields_aggregate contains any of these side types
// Check if loader_fields_aggregate contains the side types
// We assume these four fields are linked together.
if loader_fields_aggregate
.iter()
.any(|f| side_type_loader_field_names.contains(&f.as_str()))
.any(|field| field == "environment")
{
// If so, we get the fields of the example version of the project, and set the side types to match.
fields.extend(
side_type_loader_field_names
.iter()
.map(|f| (f.to_string(), json!(false))),
);
if let Some(example_version_fields) =
// If so, we get the field of an example version of the project, and set the side types to match.
fields.insert(
"environment".into(),
get_example_version_fields(
legacy_create.project_id,
client,
&redis,
)
.await?
{
fields.extend(
example_version_fields.into_iter().filter_map(
|f| {
if side_type_loader_field_names
.contains(&f.field_name.as_str())
{
Some((
f.field_name,
f.value.serialize_internal(),
))
} else {
None
}
},
),
);
}
.into_iter()
.flatten()
.find(|f| f.field_name == "environment")
.map_or(json!("unknown"), |f| {
f.value.serialize_internal()
}),
);
}
// Handle project type via file extension prediction
let mut project_type = None;

View File

@@ -164,69 +164,46 @@ where
Ok(new_multipart)
}
// Converts a "client_side" and "server_side" pair into the new v3 corresponding fields
pub fn convert_side_types_v3(
/// Converts V2 side types to V3 side types.
pub fn convert_v2_side_types_to_v3_side_types(
client_side: LegacySideType,
server_side: LegacySideType,
) -> HashMap<String, Value> {
use LegacySideType::{Optional, Required};
use LegacySideType::{Optional, Required, Unsupported};
let singleplayer = client_side == Required
|| client_side == Optional
|| server_side == Required
|| server_side == Optional;
let client_and_server = singleplayer;
let client_only = (client_side == Required || client_side == Optional)
&& server_side != Required;
let server_only = (server_side == Required || server_side == Optional)
&& client_side != Required;
let environment = match (client_side, server_side) {
(Required, Required) => "client_and_server", // Or "singleplayer_only"
(Required, Unsupported) => "client_only",
(Required, Optional) => "client_only_server_optional",
(Unsupported, Required) => "server_only", // Or "dedicated_server_only"
(Optional, Required) => "server_only_client_optional",
(Optional, Optional) => "client_or_server", // Or "client_or_server_prefers_both"
_ => "unknown",
};
let mut fields = HashMap::new();
fields.insert("singleplayer".to_string(), json!(singleplayer));
fields.insert("client_and_server".to_string(), json!(client_and_server));
fields.insert("client_only".to_string(), json!(client_only));
fields.insert("server_only".to_string(), json!(server_only));
fields
[("environment".to_string(), json!(environment))]
.into_iter()
.collect()
}
// Convert search facets from V3 back to v2
// this is not lossless. (See tests)
pub fn convert_side_types_v2(
/// Converts a V3 side types map into the corresponding V2 side types.
pub fn convert_v3_side_types_to_v2_side_types(
side_types: &HashMap<String, Value>,
project_type: Option<&str>,
) -> (LegacySideType, LegacySideType) {
let client_and_server = side_types
.get("client_and_server")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let singleplayer = side_types
.get("singleplayer")
.and_then(|x| x.as_bool())
.unwrap_or(client_and_server);
let client_only = side_types
.get("client_only")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let server_only = side_types
.get("server_only")
.and_then(|x| x.as_bool())
.unwrap_or(false);
convert_side_types_v2_bools(
Some(singleplayer),
client_only,
server_only,
Some(client_and_server),
convert_v3_environment_to_v2_side_types(
side_types
.get("environment")
.and_then(|x| x.as_str())
.unwrap_or("unknown"),
project_type,
)
}
// Client side, server side
pub fn convert_side_types_v2_bools(
singleplayer: Option<bool>,
client_only: bool,
server_only: bool,
client_and_server: Option<bool>,
/// Converts a V3 environment and project type into the corresponding V2 side types.
/// The first side type is for the client, the second is for the server.
pub fn convert_v3_environment_to_v2_side_types(
environment: &str,
project_type: Option<&str>,
) -> (LegacySideType, LegacySideType) {
use LegacySideType::{Optional, Required, Unknown, Unsupported};
@@ -236,30 +213,18 @@ pub fn convert_side_types_v2_bools(
Some("datapack") => (Optional, Required),
Some("shader") => (Required, Unsupported),
Some("resourcepack") => (Required, Unsupported),
_ => {
let singleplayer =
singleplayer.or(client_and_server).unwrap_or(false);
match (singleplayer, client_only, server_only) {
// Only singleplayer
(true, false, false) => (Required, Required),
// Client only and not server only
(false, true, false) => (Required, Unsupported),
(true, true, false) => (Required, Unsupported),
// Server only and not client only
(false, false, true) => (Unsupported, Required),
(true, false, true) => (Unsupported, Required),
// Both server only and client only
(true, true, true) => (Optional, Optional),
(false, true, true) => (Optional, Optional),
// Bad type
(false, false, false) => (Unknown, Unknown),
}
}
_ => match environment {
"client_and_server" => (Required, Required),
"client_only" => (Required, Unsupported),
"client_only_server_optional" => (Required, Optional),
"singleplayer_only" => (Required, Required),
"server_only" => (Unsupported, Required),
"server_only_client_optional" => (Optional, Required),
"dedicated_server_only" => (Unsupported, Required),
"client_or_server" => (Optional, Optional),
"client_or_server_prefers_both" => (Optional, Optional),
_ => (Unknown, Unknown), // "unknown"
},
}
}
@@ -279,13 +244,14 @@ mod tests {
};
#[test]
fn convert_types() {
// Converting types from V2 to V3 and back should be idempotent- for certain pairs
fn v2_v3_side_type_conversion() {
// Only nonsensical V2 side types cannot be round-tripped from V2 to V3 and back.
// When converting from V3 to V2, only additional information about the
// singleplayer-only, multiplayer-only, or install on both sides nature of the
// project is lost.
let lossy_pairs = [
(Optional, Unsupported),
(Unsupported, Optional),
(Required, Optional),
(Optional, Required),
(Unsupported, Unsupported),
];
@@ -294,10 +260,13 @@ mod tests {
if lossy_pairs.contains(&(client_side, server_side)) {
continue;
}
let side_types =
convert_side_types_v3(client_side, server_side);
let side_types = convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
);
let (client_side2, server_side2) =
convert_side_types_v2(&side_types, None);
convert_v3_side_types_to_v2_side_types(&side_types, None);
assert_eq!(client_side, client_side2);
assert_eq!(server_side, server_side2);
}

View File

@@ -12,7 +12,8 @@ use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::projects::{
License, Link, MonetizationStatus, ProjectStatus, VersionStatus,
License, Link, MonetizationStatus, ProjectStatus,
SideTypesMigrationReviewStatus, VersionStatus,
};
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::threads::ThreadType;
@@ -901,6 +902,9 @@ async fn project_create_inner(
color: project_builder.color,
thread_id: thread_id.into(),
monetization_status: MonetizationStatus::Monetized,
// New projects are considered reviewed with respect to side types migrations
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
fields: HashMap::new(), // Fields instantiate to empty
};

View File

@@ -17,6 +17,7 @@ use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::projects::{
MonetizationStatus, Project, ProjectStatus, SearchRequest,
SideTypesMigrationReviewStatus,
};
use crate::models::teams::ProjectPermissions;
use crate::models::threads::MessageBody;
@@ -247,6 +248,8 @@ pub struct EditProject {
#[validate(length(max = 65536))]
pub moderation_message_body: Option<Option<String>>,
pub monetization_status: Option<MonetizationStatus>,
pub side_types_migration_review_status:
Option<SideTypesMigrationReviewStatus>,
}
#[allow(clippy::too_many_arguments)]
@@ -844,6 +847,29 @@ pub async fn project_edit(
.await?;
}
if let Some(side_types_migration_review_status) =
&new_project.side_types_migration_review_status
{
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the side types migration review status of this project!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET side_types_migration_review_status = $1
WHERE id = $2
",
side_types_migration_review_status.as_str(),
id as db_ids::DBProjectId,
)
.execute(&mut *transaction)
.await?;
}
// check new description and body for links to associated images
// if they no longer exist in the description or body, delete them
let checkable_strings: Vec<&str> =

View File

@@ -362,7 +362,7 @@ pub async fn index_local(
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types);
let (client_side, server_side) =
v2_reroute::convert_side_types_v2(
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);

View File

@@ -350,11 +350,8 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[
"color",
// Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist).
// TODO: remove these- as they should be automatically populated. This is a band-aid fix.
"server_only",
"client_only",
"environment",
"game_versions",
"singleplayer",
"client_and_server",
"mrpack_loaders",
// V2 legacy fields for logical consistency
"client_side",
@@ -397,11 +394,8 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[
"color",
// Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist).
// TODO: remove these- as they should be automatically populated. This is a band-aid fix.
"server_only",
"client_only",
"environment",
"game_versions",
"singleplayer",
"client_and_server",
"mrpack_loaders",
// V2 legacy fields for logical consistency
"client_side",