From 9ef66ba2ee4c7b75298934a11be73028b084f3a1 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:38:07 +0100 Subject: [PATCH 01/13] delete loader fields migration --- .../migrations/20261013133423_delete_loader_fields.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 apps/labrinth/migrations/20261013133423_delete_loader_fields.sql diff --git a/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql new file mode 100644 index 0000000000..c4bb1742dd --- /dev/null +++ b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql @@ -0,0 +1,5 @@ +DROP TABLE version_fields; +DROP TABLE loader_fields_loaders; +DROP TABLE loader_fields; +DROP TABLE loader_field_enum_values; +DROP TABLE loader_field_enums; From 9f495e4e0c765b4ba44e27954a08ed34bbfa36db Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:51:47 +0100 Subject: [PATCH 02/13] integrate minecraft-meta db into labrinth --- Cargo.lock | 2 + apps/labrinth/.env.local | 1 + apps/labrinth/Cargo.toml | 2 + .../labrinth-seed-data-202508052143.sql | 901 ------------------ .../minecraft-meta-seed-data-202508052143.sql | 868 +++++++++++++++++ apps/labrinth/src/database/mod.rs | 17 + apps/labrinth/src/env.rs | 2 + apps/labrinth/src/lib.rs | 4 + apps/labrinth/src/main.rs | 6 + apps/labrinth/src/test/mod.rs | 7 + scripts/seed-db.sh | 21 +- 11 files changed, 928 insertions(+), 903 deletions(-) create mode 100644 apps/labrinth/fixtures/minecraft-meta-seed-data-202508052143.sql diff --git a/Cargo.lock b/Cargo.lock index 1fd3b05f51..9a8ac6053f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6750,6 +6750,7 @@ dependencies = [ "lettre", "lingua", "linkify", + "minecraft-meta-model", "modrinth-content-management", "modrinth-util", "muralpay", @@ -6789,6 +6790,7 @@ dependencies = [ "thiserror 2.0.21", "tikv-jemalloc-ctl", "tikv-jemallocator", + "toasty", "tokio", "tokio-stream", "totp-rs", diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 94feebddcc..02f3220aaf 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -16,6 +16,7 @@ RATE_LIMIT_IGNORE_KEY=feedbeef DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth DATABASE_MIN_CONNECTIONS=0 DATABASE_MAX_CONNECTIONS=16 +MINECRAFT_META_DATABASE_URL=postgresql://launchermeta:launchermeta@127.0.0.1:5433/launchermeta SEARCH_BACKEND=elasticsearch ELASTICSEARCH_URL=http://localhost:9200 diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 49511942cc..a03bb67b47 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -75,6 +75,7 @@ json-patch = { workspace = true } lettre = { workspace = true } lingua = { workspace = true } linkify = { workspace = true } +minecraft-meta-model = { workspace = true } modrinth-content-management = { workspace = true } modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } @@ -124,6 +125,7 @@ strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } tokio-stream = { workspace = true } +toasty = { workspace = true, features = ["postgresql"] } totp-rs = { workspace = true, features = ["gen_secret"] } tracing = { workspace = true } tracing-actix-web = { workspace = true } diff --git a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql index d360a6b235..bcaf6a1844 100644 --- a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql +++ b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql @@ -115,909 +115,8 @@ COPY public.loaders (id, loader, icon, hidable, metadata) FROM stdin; \. -TRUNCATE table public.loader_field_enum_values RESTART IDENTITY CASCADE; -COPY public.loader_field_enum_values (id, enum_id, value, ordering, created, metadata) FROM stdin; -2 1 unsupported \N 2025-04-03 21:55:46.229944+00 \N -3 1 unknown \N 2025-04-03 21:55:46.229944+00 \N -4 1 optional \N 2025-04-03 21:55:46.229944+00 \N -691 2 1.7.2 \N 2013-10-25 13:00:00+00 {"type": "release", "major": false} -692 2 1.7.1 \N 2013-10-23 12:01:07+00 {"type": "snapshot", "major": false} -693 2 1.7 \N 2013-10-22 15:04:05+00 {"type": "snapshot", "major": false} -694 2 13w43a \N 2013-10-21 16:34:47+00 {"type": "snapshot", "major": false} -695 2 13w42b \N 2013-10-18 16:34:08+00 {"type": "snapshot", "major": false} -17005268 4 client_and_server \N 2025-06-19 09:41:54.646734+00 \N -17005269 4 client_only \N 2025-06-19 09:41:54.646734+00 \N -17005270 4 client_only_server_optional \N 2025-06-19 09:41:54.646734+00 \N -696 2 13w42a \N 2013-10-17 18:33:05+00 {"type": "snapshot", "major": false} -697 2 13w41b \N 2013-10-11 15:09:17+00 {"type": "snapshot", "major": false} -698 2 13w41a \N 2013-10-10 14:21:43+00 {"type": "snapshot", "major": false} -699 2 13w39b \N 2013-09-27 12:15:58+00 {"type": "snapshot", "major": false} -700 2 13w39a \N 2013-09-26 15:11:19+00 {"type": "snapshot", "major": false} -701 2 13w38c \N 2013-09-20 15:11:34+00 {"type": "snapshot", "major": false} -17005271 4 singleplayer_only \N 2025-06-19 09:41:54.646734+00 \N -17005272 4 server_only \N 2025-06-19 09:41:54.646734+00 \N -17005273 4 server_only_client_optional \N 2025-06-19 09:41:54.646734+00 \N -17005274 4 dedicated_server_only \N 2025-06-19 09:41:54.646734+00 \N -17005275 4 client_or_server \N 2025-06-19 09:41:54.646734+00 \N -17005276 4 client_or_server_prefers_both \N 2025-06-19 09:41:54.646734+00 \N -17005277 4 unknown \N 2025-06-19 09:41:54.646734+00 \N -702 2 13w38b \N 2013-09-20 13:45:40+00 {"type": "snapshot", "major": false} -703 2 13w38a \N 2013-09-19 16:34:21+00 {"type": "snapshot", "major": false} -20988520 2 25w32a \N 2025-08-05 12:21:45+00 {"type": "snapshot", "major": false} -19322115 2 25w31a \N 2025-07-29 11:29:33+00 {"type": "snapshot", "major": false} -704 2 1.6.4 \N 2013-09-19 15:52:37+00 {"type": "release", "major": false} -706 2 13w37b \N 2013-09-13 10:54:42+00 {"type": "snapshot", "major": false} -17019240 2 1.21.7-rc1 \N 2025-06-25 12:41:59+00 {"type": "snapshot", "major": false} -8 2 1.21.5-rc1 \N 2025-03-20 13:45:48+00 {"type": "snapshot", "major": false} -224 2 21w39a \N 2021-09-29 16:27:05+00 {"type": "snapshot", "major": false} -231 2 1.17.1-pre2 \N 2021-06-29 15:14:12+00 {"type": "snapshot", "major": false} -264 2 20w45a \N 2020-11-04 16:42:00+00 {"type": "snapshot", "major": false} -303 2 20w13b \N 2020-03-26 13:00:34+00 {"type": "snapshot", "major": false} -705 2 1.6.3 \N 2013-09-13 10:54:41+00 {"type": "snapshot", "major": false} -312 2 1.15.2 \N 2020-01-17 10:03:52+00 {"type": "release", "major": false} -357 2 1.14.2-pre1 \N 2019-05-16 15:40:25+00 {"type": "snapshot", "major": false} -418 2 1.13-pre4 \N 2018-06-26 13:00:55+00 {"type": "snapshot", "major": false} -437 2 18w10d \N 2018-03-09 15:19:12+00 {"type": "snapshot", "major": false} -447 2 18w06a \N 2018-02-09 12:09:55+00 {"type": "snapshot", "major": false} -600 2 1.8.2-pre7 \N 2015-02-16 13:01:35+00 {"type": "snapshot", "major": false} -602 2 1.8.2-pre5 \N 2015-01-26 15:03:24+00 {"type": "snapshot", "major": false} -669 2 14w07a \N 2014-02-14 11:05:07+00 {"type": "snapshot", "major": false} -671 2 14w06a \N 2014-02-06 14:30:17+00 {"type": "snapshot", "major": false} -681 2 1.7.4 \N 2013-12-09 12:28:10+00 {"type": "release", "major": false} -682 2 1.7.3 \N 2013-12-06 13:55:34+00 {"type": "release", "major": false} -683 2 13w49a \N 2013-12-05 14:34:41+00 {"type": "snapshot", "major": false} -688 2 13w47c \N 2013-11-21 17:10:33+00 {"type": "snapshot", "major": false} -689 2 13w47b \N 2013-11-21 16:57:41+00 {"type": "snapshot", "major": false} -225 2 21w38a \N 2021-09-23 14:36:06+00 {"type": "snapshot", "major": false} -438 2 18w10c \N 2018-03-08 15:29:23+00 {"type": "snapshot", "major": false} -472 2 1.12-pre6 \N 2017-05-29 11:45:12+00 {"type": "snapshot", "major": false} -684 2 13w48b \N 2013-11-26 18:36:08+00 {"type": "snapshot", "major": false} -685 2 13w48a \N 2013-11-25 16:53:39+00 {"type": "snapshot", "major": false} -686 2 13w47e \N 2013-11-22 15:16:38+00 {"type": "snapshot", "major": false} -687 2 13w47d \N 2013-11-22 13:51:15+00 {"type": "snapshot", "major": false} -743 2 1.3.1 \N 2012-07-31 22:00:00+00 {"type": "release", "major": false} -744 2 1.3 \N 2012-07-25 22:00:00+00 {"type": "snapshot", "major": false} -746 2 1.2.4 \N 2012-03-21 22:00:00+00 {"type": "release", "major": false} -747 2 1.2.3 \N 2012-03-01 22:00:00+00 {"type": "release", "major": false} -749 2 1.2.1 \N 2012-02-29 22:00:00+00 {"type": "release", "major": false} -750 2 1.1 \N 2012-01-11 22:00:00+00 {"type": "release", "major": false} -752 2 b1.8.1 \N 2011-09-18 22:00:00+00 {"type": "beta", "major": false} -753 2 b1.8 \N 2011-09-14 22:00:00+00 {"type": "beta", "major": false} -755 2 b1.7.2 \N 2011-06-30 22:00:00+00 {"type": "beta", "major": false} -756 2 b1.7 \N 2011-06-29 22:00:00+00 {"type": "beta", "major": false} -758 2 b1.6.5 \N 2011-05-27 22:00:00+00 {"type": "beta", "major": false} -707 2 13w37a \N 2013-09-12 14:23:14+00 {"type": "snapshot", "major": false} -709 2 13w36a \N 2013-09-05 13:05:40+00 {"type": "snapshot", "major": false} -712 2 1.6 \N 2013-06-25 13:08:56+00 {"type": "snapshot", "major": false} -715 2 13w25b \N 2013-06-18 15:13:27+00 {"type": "snapshot", "major": false} -718 2 13w24a \N 2013-06-13 15:32:23+00 {"type": "snapshot", "major": false} -721 2 13w22a \N 2013-05-30 14:38:40+00 {"type": "snapshot", "major": false} -724 2 13w19a \N 2013-05-10 14:48:02+00 {"type": "snapshot", "major": false} -727 2 13w18a \N 2013-05-02 15:45:59+00 {"type": "snapshot", "major": false} -741 2 1.4 \N 2012-11-18 22:00:00+00 {"type": "snapshot", "major": false} -759 2 b1.6.4 \N 2011-05-25 22:00:04+00 {"type": "beta", "major": false} -761 2 b1.6.2 \N 2011-05-25 22:00:02+00 {"type": "beta", "major": false} -762 2 b1.6.1 \N 2011-05-25 22:00:01+00 {"type": "beta", "major": false} -764 2 b1.5_01 \N 2011-04-19 22:00:00+00 {"type": "beta", "major": false} -765 2 b1.5 \N 2011-04-18 22:00:00+00 {"type": "beta", "major": false} -79 2 24w09a \N 2024-02-28 12:38:12+00 {"type": "snapshot", "major": false} -133 2 23w12a \N 2023-03-22 13:28:42+00 {"type": "snapshot", "major": false} -112 2 23w35a \N 2023-08-30 11:24:35+00 {"type": "snapshot", "major": false} -82 2 24w05b \N 2024-02-01 12:55:14+00 {"type": "snapshot", "major": false} -85 2 24w03b \N 2024-01-18 12:42:37+00 {"type": "snapshot", "major": false} -88 2 23w51a \N 2023-12-18 13:36:46+00 {"type": "snapshot", "major": false} -91 2 1.20.3 \N 2023-12-04 12:10:32+00 {"type": "release", "major": false} -94 2 1.20.3-pre3 \N 2023-11-27 14:24:36+00 {"type": "snapshot", "major": false} -97 2 23w46a \N 2023-11-16 14:11:33+00 {"type": "snapshot", "major": false} -100 2 23w43b \N 2023-10-26 13:46:16+00 {"type": "snapshot", "major": false} -103 2 23w41a \N 2023-10-11 12:32:46+00 {"type": "snapshot", "major": false} -106 2 1.20.2-rc2 \N 2023-09-18 12:34:57+00 {"type": "snapshot", "major": false} -109 2 1.20.2-pre3 \N 2023-09-12 12:15:08+00 {"type": "snapshot", "major": false} -115 2 23w31a \N 2023-08-01 10:03:13+00 {"type": "snapshot", "major": false} -118 2 1.20 \N 2023-06-02 08:36:17+00 {"type": "release", "major": false} -124 2 1.20-pre3 \N 2023-05-19 11:39:46+00 {"type": "snapshot", "major": false} -127 2 23w18a \N 2023-05-03 11:29:26+00 {"type": "snapshot", "major": false} -136 2 1.19.4-rc2 \N 2023-03-10 12:42:54+00 {"type": "snapshot", "major": false} -139 2 1.19.4-pre3 \N 2023-03-01 14:11:05+00 {"type": "snapshot", "major": false} -142 2 23w07a \N 2023-02-15 14:32:29+00 {"type": "snapshot", "major": false} -145 2 23w04a \N 2023-01-24 15:19:06+00 {"type": "snapshot", "major": false} -148 2 1.19.3-rc3 \N 2022-12-06 10:24:01+00 {"type": "snapshot", "major": false} -151 2 1.19.3-pre3 \N 2022-11-29 14:28:08+00 {"type": "snapshot", "major": false} -154 2 22w46a \N 2022-11-16 13:32:50+00 {"type": "snapshot", "major": false} -157 2 22w43a \N 2022-10-26 11:55:59+00 {"type": "snapshot", "major": false} -160 2 1.19.2-rc2 \N 2022-08-04 15:19:44+00 {"type": "snapshot", "major": false} -163 2 1.19.1-rc3 \N 2022-07-26 15:34:35+00 {"type": "snapshot", "major": false} -166 2 1.19.1-pre5 \N 2022-07-15 11:51:44+00 {"type": "snapshot", "major": false} -169 2 1.19.1-pre2 \N 2022-06-30 15:57:20+00 {"type": "snapshot", "major": false} -175 2 1.19-rc1 \N 2022-06-02 12:12:52+00 {"type": "snapshot", "major": false} -178 2 1.19-pre3 \N 2022-05-25 09:56:47+00 {"type": "snapshot", "major": false} -181 2 22w19a \N 2022-05-12 15:36:11+00 {"type": "snapshot", "major": false} -184 2 22w16b \N 2022-04-20 17:25:32+00 {"type": "snapshot", "major": false} -187 2 22w14a \N 2022-04-06 13:37:12+00 {"type": "snapshot", "major": false} -190 2 22w12a \N 2022-03-24 16:15:02+00 {"type": "snapshot", "major": false} -193 2 1.18.2-rc1 \N 2022-02-25 13:25:40+00 {"type": "snapshot", "major": false} -199 2 22w05a \N 2022-02-02 16:08:39+00 {"type": "snapshot", "major": false} -202 2 1.18.1-rc3 \N 2021-12-10 03:36:38+00 {"type": "snapshot", "major": false} -205 2 1.18.1-pre1 \N 2021-12-03 13:45:38+00 {"type": "snapshot", "major": false} -208 2 1.18-rc3 \N 2021-11-26 15:51:56+00 {"type": "snapshot", "major": false} -17022532 2 1.21.8-rc1 \N 2025-07-15 13:42:50+00 {"type": "snapshot", "major": false} -17022533 2 1.21.7 \N 2025-06-30 09:32:16+00 {"type": "release", "major": false} -17019239 2 1.21.7-rc2 \N 2025-06-26 13:59:20+00 {"type": "snapshot", "major": false} -16819722 2 1.21.6 \N 2025-06-17 11:10:28+00 {"type": "release", "major": false} -15475421 2 1.21.6-pre4 \N 2025-06-10 12:22:36+00 {"type": "snapshot", "major": false} -13610951 2 1.21.6-pre2 \N 2025-06-02 13:40:47+00 {"type": "snapshot", "major": false} -10555651 2 25w21a \N 2025-05-20 12:09:09+00 {"type": "snapshot", "major": false} -7275618 2 25w19a \N 2025-05-06 12:57:57+00 {"type": "snapshot", "major": false} -4002026 2 25w17a \N 2025-04-22 12:51:30+00 {"type": "snapshot", "major": false} -10 2 1.21.5-pre2 \N 2025-03-12 12:36:02+00 {"type": "snapshot", "major": false} -14 2 25w09a \N 2025-02-26 15:16:02+00 {"type": "snapshot", "major": false} -16 2 25w07a \N 2025-02-13 12:55:37+00 {"type": "snapshot", "major": false} -18 2 25w05a \N 2025-01-29 14:03:54+00 {"type": "snapshot", "major": false} -20 2 25w03a \N 2025-01-15 14:28:04+00 {"type": "snapshot", "major": false} -22 2 1.21.4 \N 2024-12-03 10:12:57+00 {"type": "release", "major": false} -24 2 1.21.4-rc2 \N 2024-11-29 10:33:13+00 {"type": "snapshot", "major": false} -26 2 1.21.4-pre3 \N 2024-11-26 15:07:29+00 {"type": "snapshot", "major": false} -28 2 1.21.4-pre1 \N 2024-11-20 13:45:00+00 {"type": "snapshot", "major": false} -30 2 24w45a \N 2024-11-06 13:31:58+00 {"type": "snapshot", "major": false} -32 2 1.21.3 \N 2024-10-23 12:28:15+00 {"type": "release", "major": false} -34 2 1.21.2-rc2 \N 2024-10-21 15:53:05+00 {"type": "snapshot", "major": false} -36 2 1.21.2-pre5 \N 2024-10-16 13:30:35+00 {"type": "snapshot", "major": false} -38 2 1.21.2-pre3 \N 2024-10-11 12:32:27+00 {"type": "snapshot", "major": false} -40 2 1.21.2-pre1 \N 2024-10-08 13:22:12+00 {"type": "snapshot", "major": false} -42 2 24w39a \N 2024-09-25 13:08:41+00 {"type": "snapshot", "major": false} -44 2 24w37a \N 2024-09-11 13:01:31+00 {"type": "snapshot", "major": false} -46 2 24w35a \N 2024-08-28 12:25:10+00 {"type": "snapshot", "major": false} -48 2 24w33a \N 2024-08-15 12:39:34+00 {"type": "snapshot", "major": false} -52 2 1.21-rc1 \N 2024-06-10 12:24:08+00 {"type": "snapshot", "major": false} -54 2 1.21-pre3 \N 2024-06-05 08:51:44+00 {"type": "snapshot", "major": false} -56 2 1.21-pre1 \N 2024-05-29 12:04:43+00 {"type": "snapshot", "major": false} -61 2 24w19a \N 2024-05-10 12:15:31+00 {"type": "snapshot", "major": false} -64 2 1.20.6-rc1 \N 2024-04-26 10:12:17+00 {"type": "snapshot", "major": false} -67 2 1.20.5-rc2 \N 2024-04-19 13:13:15+00 {"type": "snapshot", "major": false} -70 2 1.20.5-pre3 \N 2024-04-16 11:57:30+00 {"type": "snapshot", "major": false} -73 2 24w14a \N 2024-04-03 11:49:39+00 {"type": "snapshot", "major": false} -76 2 24w12a \N 2024-03-20 14:38:37+00 {"type": "snapshot", "major": false} -130 2 23w14a \N 2023-04-05 12:05:17+00 {"type": "snapshot", "major": false} -172 2 22w24a \N 2022-06-15 16:21:49+00 {"type": "snapshot", "major": false} -8913813 2 25w20a \N 2025-05-13 11:46:28+00 {"type": "snapshot", "major": false} -788 2 a1.2.1 \N 2010-11-04 22:00:00+00 {"type": "alpha", "major": false} -15947165 2 1.21.6-rc1 \N 2025-06-12 12:04:37+00 {"type": "snapshot", "major": false} -12391031 2 1.21.6-pre1 \N 2025-05-28 09:34:04+00 {"type": "snapshot", "major": false} -596 2 1.8.4 \N 2015-04-17 11:37:50+00 {"type": "release", "major": false} -584 2 15w33b \N 2015-08-12 15:29:11+00 {"type": "snapshot", "major": false} -587 2 15w32b \N 2015-08-06 13:51:47+00 {"type": "snapshot", "major": false} -597 2 15w14a \N 2015-04-01 07:08:00+00 {"type": "snapshot", "major": false} -599 2 1.8.2 \N 2015-02-19 15:47:29+00 {"type": "release", "major": false} -780 2 a1.2.4_01 \N 2010-11-29 22:00:00+00 {"type": "alpha", "major": false} -782 2 a1.2.3_02 \N 2010-11-24 22:00:00+00 {"type": "alpha", "major": false} -783 2 a1.2.3_01 \N 2010-11-23 22:00:01+00 {"type": "alpha", "major": false} -785 2 a1.2.2b \N 2010-11-09 22:00:01+00 {"type": "alpha", "major": false} -786 2 a1.2.2a \N 2010-11-09 22:00:00+00 {"type": "alpha", "major": false} -14076641 2 1.21.6-pre3 \N 2025-06-04 13:33:25+00 {"type": "snapshot", "major": false} -5637002 2 25w18a \N 2025-04-29 12:21:01+00 {"type": "snapshot", "major": false} -2369876 2 25w16a \N 2025-04-15 12:01:58+00 {"type": "snapshot", "major": false} -1083550 2 25w15a \N 2025-04-08 12:16:59+00 {"type": "snapshot", "major": false} -5 2 25w14craftmine \N 2025-04-01 15:50:09+00 {"type": "snapshot", "major": false} -7 2 1.21.5-rc2 \N 2025-03-24 13:07:03+00 {"type": "snapshot", "major": false} -9 2 1.21.5-pre3 \N 2025-03-18 13:58:30+00 {"type": "snapshot", "major": false} -13 2 25w09b \N 2025-02-27 11:07:08+00 {"type": "snapshot", "major": false} -15 2 25w08a \N 2025-02-19 13:41:43+00 {"type": "snapshot", "major": false} -17 2 25w06a \N 2025-02-05 12:41:17+00 {"type": "snapshot", "major": false} -19 2 25w04a \N 2025-01-22 13:14:44+00 {"type": "snapshot", "major": false} -21 2 25w02a \N 2025-01-08 13:42:18+00 {"type": "snapshot", "major": false} -23 2 1.21.4-rc3 \N 2024-11-29 17:02:53+00 {"type": "snapshot", "major": false} -27 2 1.21.4-pre2 \N 2024-11-25 13:18:35+00 {"type": "snapshot", "major": false} -29 2 24w46a \N 2024-11-13 13:12:38+00 {"type": "snapshot", "major": false} -31 2 24w44a \N 2024-10-30 12:53:55+00 {"type": "snapshot", "major": false} -33 2 1.21.2 \N 2024-10-22 09:58:55+00 {"type": "release", "major": false} -35 2 1.21.2-rc1 \N 2024-10-17 12:43:18+00 {"type": "snapshot", "major": false} -37 2 1.21.2-pre4 \N 2024-10-15 11:59:11+00 {"type": "snapshot", "major": false} -39 2 1.21.2-pre2 \N 2024-10-10 12:59:14+00 {"type": "snapshot", "major": false} -41 2 24w40a \N 2024-10-02 13:15:42+00 {"type": "snapshot", "major": false} -43 2 24w38a \N 2024-09-18 12:32:07+00 {"type": "snapshot", "major": false} -45 2 24w36a \N 2024-09-04 12:44:12+00 {"type": "snapshot", "major": false} -47 2 24w34a \N 2024-08-21 14:14:13+00 {"type": "snapshot", "major": false} -49 2 1.21.1 \N 2024-08-08 12:24:45+00 {"type": "release", "major": false} -51 2 1.21 \N 2024-06-13 08:24:03+00 {"type": "release", "major": false} -53 2 1.21-pre4 \N 2024-06-07 12:00:15+00 {"type": "snapshot", "major": false} -58 2 24w21a \N 2024-05-22 14:18:26+00 {"type": "snapshot", "major": false} -59 2 24w20a \N 2024-05-15 12:00:35+00 {"type": "snapshot", "major": false} -60 2 24w19b \N 2024-05-10 14:32:42+00 {"type": "snapshot", "major": false} -62 2 24w18a \N 2024-05-03 12:08:27+00 {"type": "snapshot", "major": false} -546 2 15w49b \N 2015-12-03 15:23:22+00 {"type": "snapshot", "major": false} -548 2 15w49a \N 2015-12-02 15:09:37+00 {"type": "snapshot", "major": false} -549 2 15w47c \N 2015-11-20 12:46:56+00 {"type": "snapshot", "major": false} -551 2 15w47a \N 2015-11-18 15:53:41+00 {"type": "snapshot", "major": false} -552 2 15w46a \N 2015-11-12 12:11:47+00 {"type": "snapshot", "major": false} -554 2 15w44b \N 2015-10-30 11:23:17+00 {"type": "snapshot", "major": false} -555 2 15w44a \N 2015-10-28 15:09:36+00 {"type": "snapshot", "major": false} -558 2 15w43a \N 2015-10-21 15:28:52+00 {"type": "snapshot", "major": false} -560 2 15w41b \N 2015-10-07 14:07:26+00 {"type": "snapshot", "major": false} -561 2 15w41a \N 2015-10-07 13:19:53+00 {"type": "snapshot", "major": false} -563 2 15w40a \N 2015-09-30 13:13:54+00 {"type": "snapshot", "major": false} -564 2 15w39c \N 2015-09-23 13:13:54+00 {"type": "snapshot", "major": false} -566 2 15w39a \N 2015-09-21 13:16:32+00 {"type": "snapshot", "major": false} -567 2 15w38b \N 2015-09-17 14:22:31+00 {"type": "snapshot", "major": false} -569 2 15w37a \N 2015-09-10 14:22:31+00 {"type": "snapshot", "major": false} -570 2 15w36d \N 2015-09-04 14:22:31+00 {"type": "snapshot", "major": false} -572 2 15w36b \N 2015-09-02 15:36:25+00 {"type": "snapshot", "major": false} -573 2 15w36a \N 2015-09-02 14:46:40+00 {"type": "snapshot", "major": false} -575 2 15w35d \N 2015-08-28 16:25:35+00 {"type": "snapshot", "major": false} -576 2 15w35c \N 2015-08-28 11:21:00+00 {"type": "snapshot", "major": false} -578 2 15w35a \N 2015-08-24 14:19:31+00 {"type": "snapshot", "major": false} -582 2 15w34a \N 2015-08-19 12:56:01+00 {"type": "snapshot", "major": false} -588 2 15w32a \N 2015-08-05 12:22:42+00 {"type": "snapshot", "major": false} -590 2 15w31b \N 2015-07-30 13:38:32+00 {"type": "snapshot", "major": false} -591 2 15w31a \N 2015-07-29 13:24:33+00 {"type": "snapshot", "major": false} -593 2 1.8.7 \N 2015-06-05 10:10:44+00 {"type": "release", "major": false} -25 2 1.21.4-rc1 \N 2024-11-28 10:19:01+00 {"type": "snapshot", "major": false} -579 2 15w34d \N 2015-08-21 15:27:55+00 {"type": "snapshot", "major": false} -581 2 15w34b \N 2015-08-20 14:00:03+00 {"type": "snapshot", "major": false} -585 2 15w33a \N 2015-08-12 14:05:07+00 {"type": "snapshot", "major": false} -6 2 1.21.5 \N 2025-03-25 12:14:58+00 {"type": "release", "major": false} -55 2 1.21-pre2 \N 2024-05-31 12:44:56+00 {"type": "snapshot", "major": false} -557 2 15w43b \N 2015-10-22 14:11:58+00 {"type": "snapshot", "major": false} -57 2 24w21b \N 2024-05-22 16:25:41+00 {"type": "snapshot", "major": false} -63 2 1.20.6 \N 2024-04-29 12:40:45+00 {"type": "release", "major": false} -65 2 1.20.5 \N 2024-04-23 11:54:12+00 {"type": "release", "major": false} -66 2 1.20.5-rc3 \N 2024-04-22 13:42:34+00 {"type": "snapshot", "major": false} -68 2 1.20.5-rc1 \N 2024-04-18 11:45:40+00 {"type": "snapshot", "major": false} -69 2 1.20.5-pre4 \N 2024-04-17 11:56:02+00 {"type": "snapshot", "major": false} -71 2 1.20.5-pre2 \N 2024-04-15 12:36:05+00 {"type": "snapshot", "major": false} -72 2 1.20.5-pre1 \N 2024-04-10 12:44:25+00 {"type": "snapshot", "major": false} -74 2 24w14potato \N 2024-04-01 11:07:19+00 {"type": "snapshot", "major": false} -75 2 24w13a \N 2024-03-27 14:30:20+00 {"type": "snapshot", "major": false} -77 2 24w11a \N 2024-03-14 14:21:33+00 {"type": "snapshot", "major": false} -78 2 24w10a \N 2024-03-06 10:37:35+00 {"type": "snapshot", "major": false} -80 2 24w07a \N 2024-02-14 12:51:01+00 {"type": "snapshot", "major": false} -81 2 24w06a \N 2024-02-07 14:47:18+00 {"type": "snapshot", "major": false} -83 2 24w05a \N 2024-01-31 13:05:26+00 {"type": "snapshot", "major": false} -84 2 24w04a \N 2024-01-24 13:42:45+00 {"type": "snapshot", "major": false} -99 2 23w44a \N 2023-11-01 12:30:52+00 {"type": "snapshot", "major": false} -101 2 23w43a \N 2023-10-25 13:34:37+00 {"type": "snapshot", "major": false} -102 2 23w42a \N 2023-10-18 11:37:28+00 {"type": "snapshot", "major": false} -104 2 23w40a \N 2023-10-04 12:48:53+00 {"type": "snapshot", "major": false} -105 2 1.20.2 \N 2023-09-20 09:02:57+00 {"type": "release", "major": false} -107 2 1.20.2-rc1 \N 2023-09-15 13:10:30+00 {"type": "snapshot", "major": false} -108 2 1.20.2-pre4 \N 2023-09-13 15:06:51+00 {"type": "snapshot", "major": false} -110 2 1.20.2-pre2 \N 2023-09-07 12:42:32+00 {"type": "snapshot", "major": false} -111 2 1.20.2-pre1 \N 2023-09-05 12:06:20+00 {"type": "snapshot", "major": false} -113 2 23w33a \N 2023-08-17 11:39:08+00 {"type": "snapshot", "major": false} -114 2 23w32a \N 2023-08-09 12:14:25+00 {"type": "snapshot", "major": false} -116 2 1.20.1 \N 2023-06-12 13:25:51+00 {"type": "release", "major": false} -117 2 1.20.1-rc1 \N 2023-06-09 14:15:49+00 {"type": "snapshot", "major": false} -119 2 1.20-rc1 \N 2023-05-31 12:33:33+00 {"type": "snapshot", "major": false} -120 2 1.20-pre7 \N 2023-05-29 13:44:34+00 {"type": "snapshot", "major": false} -122 2 1.20-pre5 \N 2023-05-23 12:22:52+00 {"type": "snapshot", "major": false} -123 2 1.20-pre4 \N 2023-05-19 13:13:45+00 {"type": "snapshot", "major": false} -125 2 1.20-pre2 \N 2023-05-16 11:34:54+00 {"type": "snapshot", "major": false} -126 2 1.20-pre1 \N 2023-05-10 12:19:34+00 {"type": "snapshot", "major": false} -128 2 23w17a \N 2023-04-26 12:09:48+00 {"type": "snapshot", "major": false} -129 2 23w16a \N 2023-04-20 11:55:19+00 {"type": "snapshot", "major": false} -131 2 23w13a_or_b \N 2023-04-01 12:52:18+00 {"type": "snapshot", "major": false} -132 2 23w13a \N 2023-03-29 13:54:16+00 {"type": "snapshot", "major": false} -134 2 1.19.4 \N 2023-03-14 12:56:18+00 {"type": "release", "major": false} -86 2 24w03a \N 2024-01-17 13:19:20+00 {"type": "snapshot", "major": false} -87 2 23w51b \N 2023-12-18 15:39:14+00 {"type": "snapshot", "major": false} -89 2 1.20.4 \N 2023-12-07 12:56:20+00 {"type": "release", "major": false} -90 2 1.20.4-rc1 \N 2023-12-06 14:38:01+00 {"type": "snapshot", "major": false} -92 2 1.20.3-rc1 \N 2023-11-30 13:41:45+00 {"type": "snapshot", "major": false} -93 2 1.20.3-pre4 \N 2023-11-28 13:47:32+00 {"type": "snapshot", "major": false} -95 2 1.20.3-pre2 \N 2023-11-22 12:21:26+00 {"type": "snapshot", "major": false} -96 2 1.20.3-pre1 \N 2023-11-20 15:40:14+00 {"type": "snapshot", "major": false} -98 2 23w45a \N 2023-11-08 13:59:58+00 {"type": "snapshot", "major": false} -168 2 1.19.1-pre3 \N 2022-07-06 14:50:46+00 {"type": "snapshot", "major": false} -170 2 1.19.1-rc1 \N 2022-06-23 16:32:41+00 {"type": "snapshot", "major": false} -171 2 1.19.1-pre1 \N 2022-06-21 17:13:59+00 {"type": "snapshot", "major": false} -173 2 1.19 \N 2022-06-07 09:42:18+00 {"type": "release", "major": false} -242 2 21w19a \N 2021-05-12 11:19:15+00 {"type": "snapshot", "major": false} -243 2 21w18a \N 2021-05-05 15:24:35+00 {"type": "snapshot", "major": false} -255 2 21w05b \N 2021-02-04 15:09:29+00 {"type": "snapshot", "major": false} -174 2 1.19-rc2 \N 2022-06-03 11:47:25+00 {"type": "snapshot", "major": false} -177 2 1.19-pre4 \N 2022-05-30 14:43:01+00 {"type": "snapshot", "major": false} -179 2 1.19-pre2 \N 2022-05-23 14:54:00+00 {"type": "snapshot", "major": false} -180 2 1.19-pre1 \N 2022-05-18 13:51:54+00 {"type": "snapshot", "major": false} -182 2 22w18a \N 2022-05-04 14:41:35+00 {"type": "snapshot", "major": false} -185 2 22w16a \N 2022-04-20 14:37:07+00 {"type": "snapshot", "major": false} -186 2 22w15a \N 2022-04-13 15:41:17+00 {"type": "snapshot", "major": false} -188 2 22w13oneblockatatime \N 2022-04-01 11:56:58+00 {"type": "snapshot", "major": false} -189 2 22w13a \N 2022-03-31 14:53:25+00 {"type": "snapshot", "major": false} -192 2 1.18.2 \N 2022-02-28 10:42:45+00 {"type": "release", "major": false} -200 2 22w03a \N 2022-01-19 16:04:59+00 {"type": "snapshot", "major": false} -201 2 1.18.1 \N 2021-12-10 08:23:00+00 {"type": "release", "major": false} -203 2 1.18.1-rc2 \N 2021-12-08 12:29:36+00 {"type": "snapshot", "major": false} -204 2 1.18.1-rc1 \N 2021-12-07 15:52:47+00 {"type": "snapshot", "major": false} -206 2 1.18 \N 2021-11-30 09:16:29+00 {"type": "release", "major": false} -207 2 1.18-rc4 \N 2021-11-29 13:43:42+00 {"type": "snapshot", "major": false} -209 2 1.18-rc2 \N 2021-11-26 10:02:04+00 {"type": "snapshot", "major": false} -210 2 1.18-rc1 \N 2021-11-25 14:28:49+00 {"type": "snapshot", "major": false} -212 2 1.18-pre7 \N 2021-11-23 16:37:41+00 {"type": "snapshot", "major": false} -215 2 1.18-pre4 \N 2021-11-17 18:07:56+00 {"type": "snapshot", "major": false} -216 2 1.18-pre3 \N 2021-11-17 16:04:25+00 {"type": "snapshot", "major": false} -218 2 1.18-pre1 \N 2021-11-11 16:14:06+00 {"type": "snapshot", "major": false} -219 2 21w44a \N 2021-11-03 16:14:34+00 {"type": "snapshot", "major": false} -221 2 21w42a \N 2021-10-20 12:41:25+00 {"type": "snapshot", "major": false} -222 2 21w41a \N 2021-10-13 15:23:23+00 {"type": "snapshot", "major": false} -227 2 1.17.1 \N 2021-07-06 12:01:34+00 {"type": "release", "major": false} -228 2 1.17.1-rc2 \N 2021-07-05 12:58:01+00 {"type": "snapshot", "major": false} -230 2 1.17.1-pre3 \N 2021-06-30 15:43:16+00 {"type": "snapshot", "major": false} -233 2 1.17 \N 2021-06-08 11:00:40+00 {"type": "release", "major": false} -234 2 1.17-rc2 \N 2021-06-07 11:46:28+00 {"type": "snapshot", "major": false} -236 2 1.17-pre5 \N 2021-06-03 17:01:28+00 {"type": "snapshot", "major": false} -237 2 1.17-pre4 \N 2021-06-02 16:15:43+00 {"type": "snapshot", "major": false} -239 2 1.17-pre2 \N 2021-05-31 15:54:05+00 {"type": "snapshot", "major": false} -245 2 21w16a \N 2021-04-21 16:41:14+00 {"type": "snapshot", "major": false} -246 2 21w15a \N 2021-04-14 13:41:34+00 {"type": "snapshot", "major": false} -248 2 21w13a \N 2021-03-31 16:17:46+00 {"type": "snapshot", "major": false} -249 2 21w11a \N 2021-03-17 15:05:50+00 {"type": "snapshot", "major": false} -251 2 21w08b \N 2021-02-25 11:46:34+00 {"type": "snapshot", "major": false} -252 2 21w08a \N 2021-02-24 14:38:51+00 {"type": "snapshot", "major": false} -257 2 21w03a \N 2021-01-20 14:56:29+00 {"type": "snapshot", "major": false} -258 2 1.16.5 \N 2021-01-14 16:05:32+00 {"type": "release", "major": false} -260 2 20w51a \N 2020-12-16 16:27:57+00 {"type": "snapshot", "major": false} -261 2 20w49a \N 2020-12-02 16:47:20+00 {"type": "snapshot", "major": false} -137 2 1.19.4-rc1 \N 2023-03-09 14:35:50+00 {"type": "snapshot", "major": false} -140 2 1.19.4-pre2 \N 2023-02-27 13:38:53+00 {"type": "snapshot", "major": false} -141 2 1.19.4-pre1 \N 2023-02-22 16:00:34+00 {"type": "snapshot", "major": false} -143 2 23w06a \N 2023-02-08 15:00:04+00 {"type": "snapshot", "major": false} -144 2 23w05a \N 2023-02-01 14:20:33+00 {"type": "snapshot", "major": false} -146 2 23w03a \N 2023-01-18 13:10:31+00 {"type": "snapshot", "major": false} -147 2 1.19.3 \N 2022-12-07 08:17:18+00 {"type": "release", "major": false} -149 2 1.19.3-rc2 \N 2022-12-05 13:21:34+00 {"type": "snapshot", "major": false} -150 2 1.19.3-rc1 \N 2022-12-01 13:45:18+00 {"type": "snapshot", "major": false} -152 2 1.19.3-pre2 \N 2022-11-23 16:12:25+00 {"type": "snapshot", "major": false} -153 2 1.19.3-pre1 \N 2022-11-22 13:59:37+00 {"type": "snapshot", "major": false} -155 2 22w45a \N 2022-11-09 14:30:16+00 {"type": "snapshot", "major": false} -156 2 22w44a \N 2022-11-02 13:15:43+00 {"type": "snapshot", "major": false} -158 2 22w42a \N 2022-10-19 09:34:22+00 {"type": "snapshot", "major": false} -159 2 1.19.2 \N 2022-08-05 11:57:05+00 {"type": "release", "major": false} -161 2 1.19.2-rc1 \N 2022-08-04 10:07:26+00 {"type": "snapshot", "major": false} -162 2 1.19.1 \N 2022-07-27 09:25:33+00 {"type": "release", "major": false} -164 2 1.19.1-rc2 \N 2022-07-21 16:25:50+00 {"type": "snapshot", "major": false} -165 2 1.19.1-pre6 \N 2022-07-20 15:49:31+00 {"type": "snapshot", "major": false} -167 2 1.19.1-pre4 \N 2022-07-08 11:41:59+00 {"type": "snapshot", "major": false} -183 2 22w17a \N 2022-04-27 15:54:15+00 {"type": "snapshot", "major": false} -191 2 22w11a \N 2022-03-16 15:55:38+00 {"type": "snapshot", "major": false} -240 2 1.17-pre1 \N 2021-05-27 09:39:21+00 {"type": "snapshot", "major": false} -194 2 1.18.2-pre3 \N 2022-02-23 15:23:12+00 {"type": "snapshot", "major": false} -195 2 1.18.2-pre2 \N 2022-02-21 15:26:19+00 {"type": "snapshot", "major": false} -197 2 22w07a \N 2022-02-16 16:13:58+00 {"type": "snapshot", "major": false} -198 2 22w06a \N 2022-02-09 16:47:48+00 {"type": "snapshot", "major": false} -430 2 18w19b \N 2018-05-09 10:00:51+00 {"type": "snapshot", "major": false} -433 2 18w15a \N 2018-04-11 14:54:22+00 {"type": "snapshot", "major": false} -436 2 18w11a \N 2018-03-13 15:10:59+00 {"type": "snapshot", "major": false} -439 2 18w10b \N 2018-03-07 15:56:01+00 {"type": "snapshot", "major": false} -334 2 19w38b \N 2019-09-18 14:59:13+00 {"type": "snapshot", "major": false} -442 2 18w08b \N 2018-02-22 15:44:49+00 {"type": "snapshot", "major": false} -445 2 18w07b \N 2018-02-15 14:28:42+00 {"type": "snapshot", "major": false} -448 2 18w05a \N 2018-01-31 13:32:09+00 {"type": "snapshot", "major": false} -451 2 18w02a \N 2018-01-10 11:54:55+00 {"type": "snapshot", "major": false} -454 2 17w49b \N 2017-12-07 15:29:54+00 {"type": "snapshot", "major": false} -457 2 17w47b \N 2017-11-23 15:30:12+00 {"type": "snapshot", "major": false} -460 2 17w45b \N 2017-11-10 10:07:02+00 {"type": "snapshot", "major": false} -466 2 1.12.2-pre1 \N 2017-09-13 13:33:31+00 {"type": "snapshot", "major": false} -214 2 1.18-pre5 \N 2021-11-19 15:47:09+00 {"type": "snapshot", "major": false} -217 2 1.18-pre2 \N 2021-11-16 17:04:48+00 {"type": "snapshot", "major": false} -220 2 21w43a \N 2021-10-27 14:38:55+00 {"type": "snapshot", "major": false} -223 2 21w40a \N 2021-10-07 11:17:50+00 {"type": "snapshot", "major": false} -226 2 21w37a \N 2021-09-15 16:04:30+00 {"type": "snapshot", "major": false} -229 2 1.17.1-rc1 \N 2021-07-01 15:23:37+00 {"type": "snapshot", "major": false} -232 2 1.17.1-pre1 \N 2021-06-18 12:24:40+00 {"type": "snapshot", "major": false} -235 2 1.17-rc1 \N 2021-06-04 13:24:48+00 {"type": "snapshot", "major": false} -241 2 21w20a \N 2021-05-19 15:22:02+00 {"type": "snapshot", "major": false} -244 2 21w17a \N 2021-04-28 13:54:05+00 {"type": "snapshot", "major": false} -247 2 21w14a \N 2021-04-07 14:04:09+00 {"type": "snapshot", "major": false} -250 2 21w10a \N 2021-03-10 15:24:38+00 {"type": "snapshot", "major": false} -253 2 21w07a \N 2021-02-17 16:35:40+00 {"type": "snapshot", "major": false} -256 2 21w05a \N 2021-02-03 15:56:54+00 {"type": "snapshot", "major": false} -259 2 1.16.5-rc1 \N 2021-01-13 15:58:55+00 {"type": "snapshot", "major": false} -262 2 20w48a \N 2020-11-25 15:42:24+00 {"type": "snapshot", "major": false} -265 2 1.16.4 \N 2020-10-29 15:49:37+00 {"type": "release", "major": false} -268 2 1.16.4-pre1 \N 2020-10-13 14:36:07+00 {"type": "snapshot", "major": false} -271 2 1.16.2 \N 2020-08-11 10:13:46+00 {"type": "release", "major": false} -274 2 1.16.2-pre3 \N 2020-08-06 16:44:52+00 {"type": "snapshot", "major": false} -277 2 20w30a \N 2020-07-22 15:05:15+00 {"type": "snapshot", "major": false} -283 2 1.16-rc1 \N 2020-06-18 12:49:28+00 {"type": "snapshot", "major": false} -286 2 1.16-pre6 \N 2020-06-15 16:57:57+00 {"type": "snapshot", "major": false} -289 2 1.16-pre3 \N 2020-06-10 14:57:43+00 {"type": "snapshot", "major": false} -292 2 20w22a \N 2020-05-29 11:25:02+00 {"type": "snapshot", "major": false} -295 2 20w20a \N 2020-05-13 15:11:43+00 {"type": "snapshot", "major": false} -298 2 20w17a \N 2020-04-22 13:47:50+00 {"type": "snapshot", "major": false} -301 2 20w14a \N 2020-04-02 14:28:06+00 {"type": "snapshot", "major": false} -304 2 20w13a \N 2020-03-25 17:05:33+00 {"type": "snapshot", "major": false} -310 2 20w07a \N 2020-02-14 13:20:49+00 {"type": "snapshot", "major": false} -316 2 1.15.1-pre1 \N 2019-12-12 14:02:30+00 {"type": "snapshot", "major": false} -319 2 1.15-pre6 \N 2019-12-06 12:04:30+00 {"type": "snapshot", "major": false} -322 2 1.15-pre3 \N 2019-11-28 17:17:50+00 {"type": "snapshot", "major": false} -325 2 19w46b \N 2019-11-14 13:29:24+00 {"type": "snapshot", "major": false} -328 2 19w45a \N 2019-11-07 16:19:20+00 {"type": "snapshot", "major": false} -331 2 19w41a \N 2019-10-09 15:21:35+00 {"type": "snapshot", "major": false} -337 2 19w36a \N 2019-09-04 11:19:34+00 {"type": "snapshot", "major": false} -340 2 1.14.4 \N 2019-07-19 09:25:47+00 {"type": "release", "major": false} -343 2 1.14.4-pre5 \N 2019-07-11 10:52:33+00 {"type": "snapshot", "major": false} -346 2 1.14.4-pre2 \N 2019-07-04 14:41:05+00 {"type": "snapshot", "major": false} -349 2 1.14.3-pre4 \N 2019-06-19 11:44:29+00 {"type": "snapshot", "major": false} -352 2 1.14.3-pre1 \N 2019-06-03 14:34:20+00 {"type": "snapshot", "major": false} -355 2 1.14.2-pre3 \N 2019-05-22 13:12:51+00 {"type": "snapshot", "major": false} -358 2 1.14.1 \N 2019-05-13 11:10:12+00 {"type": "release", "major": false} -361 2 1.14 \N 2019-04-23 14:52:44+00 {"type": "release", "major": false} -364 2 1.14-pre3 \N 2019-04-16 13:57:10+00 {"type": "snapshot", "major": false} -367 2 19w14b \N 2019-04-05 10:33:58+00 {"type": "snapshot", "major": false} -370 2 19w13b \N 2019-03-29 16:53:22+00 {"type": "snapshot", "major": false} -373 2 19w12a \N 2019-03-20 16:47:34+00 {"type": "snapshot", "major": false} -376 2 19w09a \N 2019-02-27 14:44:30+00 {"type": "snapshot", "major": false} -379 2 19w07a \N 2019-02-13 16:12:08+00 {"type": "snapshot", "major": false} -382 2 19w04b \N 2019-01-25 12:20:15+00 {"type": "snapshot", "major": false} -385 2 19w03b \N 2019-01-17 16:43:27+00 {"type": "snapshot", "major": false} -388 2 18w50a \N 2018-12-12 14:58:13+00 {"type": "snapshot", "major": false} -391 2 18w48a \N 2018-11-29 13:11:38+00 {"type": "snapshot", "major": false} -397 2 18w43c \N 2018-10-26 08:40:46+00 {"type": "snapshot", "major": false} -400 2 1.13.2 \N 2018-10-22 11:41:07+00 {"type": "release", "major": false} -403 2 1.13.1 \N 2018-08-22 14:03:42+00 {"type": "release", "major": false} -406 2 18w33a \N 2018-08-15 14:28:56+00 {"type": "snapshot", "major": false} -409 2 18w30b \N 2018-07-26 16:06:57+00 {"type": "snapshot", "major": false} -412 2 1.13-pre10 \N 2018-07-17 14:48:06+00 {"type": "snapshot", "major": false} -415 2 1.13-pre7 \N 2018-07-10 14:21:42+00 {"type": "snapshot", "major": false} -421 2 1.13-pre1 \N 2018-06-04 15:17:34+00 {"type": "snapshot", "major": false} -424 2 18w22a \N 2018-05-29 13:23:55+00 {"type": "snapshot", "major": false} -427 2 18w20c \N 2018-05-17 14:06:56+00 {"type": "snapshot", "major": false} -280 2 20w27a \N 2020-07-01 15:07:35+00 {"type": "snapshot", "major": false} -307 2 20w10a \N 2020-03-04 16:21:41+00 {"type": "snapshot", "major": false} -365 2 1.14-pre2 \N 2019-04-12 11:38:53+00 {"type": "snapshot", "major": false} -266 2 1.16.4-rc1 \N 2020-10-27 16:31:08+00 {"type": "snapshot", "major": false} -318 2 1.15-pre7 \N 2019-12-09 12:14:11+00 {"type": "snapshot", "major": false} -275 2 1.16.2-pre2 \N 2020-08-05 15:30:50+00 {"type": "snapshot", "major": false} -267 2 1.16.4-pre2 \N 2020-10-22 15:32:17+00 {"type": "snapshot", "major": false} -269 2 1.16.3 \N 2020-09-10 13:42:37+00 {"type": "release", "major": false} -272 2 1.16.2-rc2 \N 2020-08-10 11:43:36+00 {"type": "snapshot", "major": false} -276 2 1.16.2-pre1 \N 2020-07-29 13:19:05+00 {"type": "snapshot", "major": false} -278 2 20w29a \N 2020-07-15 14:13:47+00 {"type": "snapshot", "major": false} -279 2 20w28a \N 2020-07-08 15:10:40+00 {"type": "snapshot", "major": false} -282 2 1.16 \N 2020-06-23 16:20:52+00 {"type": "release", "major": false} -285 2 1.16-pre7 \N 2020-06-16 15:31:35+00 {"type": "snapshot", "major": false} -287 2 1.16-pre5 \N 2020-06-12 14:33:59+00 {"type": "snapshot", "major": false} -290 2 1.16-pre2 \N 2020-06-05 10:47:59+00 {"type": "snapshot", "major": false} -291 2 1.16-pre1 \N 2020-06-04 18:17:51+00 {"type": "snapshot", "major": false} -293 2 20w21a \N 2020-05-20 12:07:18+00 {"type": "snapshot", "major": false} -294 2 20w20b \N 2020-05-14 08:16:26+00 {"type": "snapshot", "major": false} -296 2 20w19a \N 2020-05-06 16:23:24+00 {"type": "snapshot", "major": false} -297 2 20w18a \N 2020-04-29 15:16:34+00 {"type": "snapshot", "major": false} -300 2 20w15a \N 2020-04-08 12:29:24+00 {"type": "snapshot", "major": false} -302 2 20w14infinite \N 2020-04-01 12:47:08+00 {"type": "snapshot", "major": false} -305 2 20w12a \N 2020-03-18 16:42:06+00 {"type": "snapshot", "major": false} -306 2 20w11a \N 2020-03-11 16:28:27+00 {"type": "snapshot", "major": false} -309 2 20w08a \N 2020-02-19 13:30:09+00 {"type": "snapshot", "major": false} -311 2 20w06a \N 2020-02-05 16:05:22+00 {"type": "snapshot", "major": false} -315 2 1.15.1 \N 2019-12-16 10:29:47+00 {"type": "release", "major": false} -317 2 1.15 \N 2019-12-09 13:13:38+00 {"type": "release", "major": false} -320 2 1.15-pre5 \N 2019-12-05 13:20:00+00 {"type": "snapshot", "major": false} -321 2 1.15-pre4 \N 2019-12-03 12:24:24+00 {"type": "snapshot", "major": false} -323 2 1.15-pre2 \N 2019-11-25 18:09:38+00 {"type": "snapshot", "major": false} -324 2 1.15-pre1 \N 2019-11-21 17:01:17+00 {"type": "snapshot", "major": false} -326 2 19w46a \N 2019-11-13 16:37:46+00 {"type": "snapshot", "major": false} -327 2 19w45b \N 2019-11-08 12:42:44+00 {"type": "snapshot", "major": false} -333 2 19w39a \N 2019-09-27 10:13:33+00 {"type": "snapshot", "major": false} -335 2 19w38a \N 2019-09-18 10:03:22+00 {"type": "snapshot", "major": false} -336 2 19w37a \N 2019-09-11 11:46:44+00 {"type": "snapshot", "major": false} -338 2 19w35a \N 2019-08-28 15:01:44+00 {"type": "snapshot", "major": false} -339 2 19w34a \N 2019-08-22 12:06:21+00 {"type": "snapshot", "major": false} -341 2 1.14.4-pre7 \N 2019-07-18 11:32:36+00 {"type": "snapshot", "major": false} -342 2 1.14.4-pre6 \N 2019-07-15 12:39:49+00 {"type": "snapshot", "major": false} -344 2 1.14.4-pre4 \N 2019-07-10 12:53:29+00 {"type": "snapshot", "major": false} -345 2 1.14.4-pre3 \N 2019-07-08 11:21:42+00 {"type": "snapshot", "major": false} -347 2 1.14.4-pre1 \N 2019-07-03 13:01:01+00 {"type": "snapshot", "major": false} -348 2 1.14.3 \N 2019-06-24 12:52:52+00 {"type": "release", "major": false} -351 2 1.14.3-pre2 \N 2019-06-07 09:11:29+00 {"type": "snapshot", "major": false} -353 2 1.14.2 \N 2019-05-27 11:48:25+00 {"type": "release", "major": false} -354 2 1.14.2-pre4 \N 2019-05-27 07:21:11+00 {"type": "snapshot", "major": false} -356 2 1.14.2-pre2 \N 2019-05-17 12:21:03+00 {"type": "snapshot", "major": false} -359 2 1.14.1-pre2 \N 2019-05-09 14:01:04+00 {"type": "snapshot", "major": false} -360 2 1.14.1-pre1 \N 2019-05-07 14:44:42+00 {"type": "snapshot", "major": false} -362 2 1.14-pre5 \N 2019-04-18 11:05:19+00 {"type": "snapshot", "major": false} -363 2 1.14-pre4 \N 2019-04-17 15:31:12+00 {"type": "snapshot", "major": false} -366 2 1.14-pre1 \N 2019-04-10 14:24:16+00 {"type": "snapshot", "major": false} -368 2 19w14a \N 2019-04-03 13:45:00+00 {"type": "snapshot", "major": false} -371 2 19w13a \N 2019-03-27 15:15:31+00 {"type": "snapshot", "major": false} -372 2 19w12b \N 2019-03-21 15:20:01+00 {"type": "snapshot", "major": false} -374 2 19w11b \N 2019-03-14 14:26:23+00 {"type": "snapshot", "major": false} -375 2 19w11a \N 2019-03-13 13:59:29+00 {"type": "snapshot", "major": false} -377 2 19w08b \N 2019-02-21 13:38:09+00 {"type": "snapshot", "major": false} -378 2 19w08a \N 2019-02-20 14:56:58+00 {"type": "snapshot", "major": false} -380 2 19w06a \N 2019-02-06 16:24:13+00 {"type": "snapshot", "major": false} -381 2 19w05a \N 2019-01-30 15:16:49+00 {"type": "snapshot", "major": false} -383 2 19w04a \N 2019-01-24 15:31:52+00 {"type": "snapshot", "major": false} -384 2 19w03c \N 2019-01-18 11:27:13+00 {"type": "snapshot", "major": false} -386 2 19w03a \N 2019-01-16 16:45:02+00 {"type": "snapshot", "major": false} -387 2 19w02a \N 2019-01-09 15:52:07+00 {"type": "snapshot", "major": false} -389 2 18w49a \N 2018-12-05 12:24:30+00 {"type": "snapshot", "major": false} -270 2 1.16.3-rc1 \N 2020-09-07 12:34:06+00 {"type": "snapshot", "major": false} -273 2 1.16.2-rc1 \N 2020-08-07 14:35:39+00 {"type": "snapshot", "major": false} -284 2 1.16-pre8 \N 2020-06-17 14:45:23+00 {"type": "snapshot", "major": false} -288 2 1.16-pre4 \N 2020-06-11 15:45:55+00 {"type": "snapshot", "major": false} -299 2 20w16a \N 2020-04-15 14:13:01+00 {"type": "snapshot", "major": false} -308 2 20w09a \N 2020-02-26 16:43:08+00 {"type": "snapshot", "major": false} -329 2 19w44a \N 2019-10-30 15:31:44+00 {"type": "snapshot", "major": false} -330 2 19w42a \N 2019-10-16 15:30:39+00 {"type": "snapshot", "major": false} -332 2 19w40a \N 2019-10-02 13:40:26+00 {"type": "snapshot", "major": false} -369 2 3D-Shareware-v1.34 \N 2019-04-01 11:18:08+00 {"type": "snapshot", "major": false} -417 2 1.13-pre5 \N 2018-06-28 13:58:53+00 {"type": "snapshot", "major": false} -420 2 1.13-pre2 \N 2018-06-15 09:20:00+00 {"type": "snapshot", "major": false} -422 2 18w22c \N 2018-05-31 13:53:15+00 {"type": "snapshot", "major": false} -503 2 16w36a \N 2016-09-08 14:55:10+00 {"type": "snapshot", "major": false} -507 2 16w32a \N 2016-08-10 12:30:10+00 {"type": "snapshot", "major": false} -509 2 1.10.1 \N 2016-06-22 10:13:22+00 {"type": "release", "major": false} -423 2 18w22b \N 2018-05-30 13:48:58+00 {"type": "snapshot", "major": false} -426 2 18w21a \N 2018-05-23 13:11:49+00 {"type": "snapshot", "major": false} -431 2 18w19a \N 2018-05-08 13:05:19+00 {"type": "snapshot", "major": false} -432 2 18w16a \N 2018-04-19 14:46:35+00 {"type": "snapshot", "major": false} -434 2 18w14b \N 2018-04-05 14:44:02+00 {"type": "snapshot", "major": false} -435 2 18w14a \N 2018-04-04 14:36:14+00 {"type": "snapshot", "major": false} -440 2 18w10a \N 2018-03-06 15:54:24+00 {"type": "snapshot", "major": false} -441 2 18w09a \N 2018-03-01 14:15:10+00 {"type": "snapshot", "major": false} -443 2 18w08a \N 2018-02-21 14:59:00+00 {"type": "snapshot", "major": false} -444 2 18w07c \N 2018-02-16 13:23:32+00 {"type": "snapshot", "major": false} -446 2 18w07a \N 2018-02-14 17:34:13+00 {"type": "snapshot", "major": false} -450 2 18w03a \N 2018-01-17 14:25:24+00 {"type": "snapshot", "major": false} -452 2 18w01a \N 2018-01-03 13:29:30+00 {"type": "snapshot", "major": false} -453 2 17w50a \N 2017-12-11 15:28:08+00 {"type": "snapshot", "major": false} -455 2 17w49a \N 2017-12-06 14:24:30+00 {"type": "snapshot", "major": false} -456 2 17w48a \N 2017-11-27 15:36:33+00 {"type": "snapshot", "major": false} -458 2 17w47a \N 2017-11-22 12:40:05+00 {"type": "snapshot", "major": false} -459 2 17w46a \N 2017-11-15 15:21:55+00 {"type": "snapshot", "major": false} -461 2 17w45a \N 2017-11-08 15:48:00+00 {"type": "snapshot", "major": false} -464 2 1.12.2 \N 2017-09-18 08:39:46+00 {"type": "release", "major": false} -467 2 1.12.1 \N 2017-08-03 12:40:39+00 {"type": "release", "major": false} -468 2 1.12.1-pre1 \N 2017-08-02 10:53:55+00 {"type": "snapshot", "major": false} -470 2 1.12 \N 2017-06-02 13:50:27+00 {"type": "release", "major": false} -471 2 1.12-pre7 \N 2017-05-31 10:56:41+00 {"type": "snapshot", "major": false} -473 2 1.12-pre5 \N 2017-05-19 07:43:28+00 {"type": "snapshot", "major": false} -474 2 1.12-pre4 \N 2017-05-18 12:28:16+00 {"type": "snapshot", "major": false} -476 2 1.12-pre2 \N 2017-05-11 12:11:12+00 {"type": "snapshot", "major": false} -477 2 1.12-pre1 \N 2017-05-10 11:37:17+00 {"type": "snapshot", "major": false} -479 2 17w18a \N 2017-05-03 14:50:23+00 {"type": "snapshot", "major": false} -480 2 17w17b \N 2017-04-27 13:24:23+00 {"type": "snapshot", "major": false} -482 2 17w16b \N 2017-04-21 12:02:59+00 {"type": "snapshot", "major": false} -485 2 17w14a \N 2017-04-05 13:58:01+00 {"type": "snapshot", "major": false} -486 2 17w13b \N 2017-03-31 11:06:35+00 {"type": "snapshot", "major": false} -488 2 17w06a \N 2017-02-08 13:16:29+00 {"type": "snapshot", "major": false} -489 2 1.11.2 \N 2016-12-21 09:29:12+00 {"type": "release", "major": false} -491 2 16w50a \N 2016-12-15 14:38:52+00 {"type": "snapshot", "major": false} -492 2 1.11 \N 2016-11-14 14:34:40+00 {"type": "release", "major": false} -494 2 16w44a \N 2016-11-03 14:17:11+00 {"type": "snapshot", "major": false} -495 2 16w43a \N 2016-10-27 09:00:51+00 {"type": "snapshot", "major": false} -498 2 16w40a \N 2016-10-06 13:57:59+00 {"type": "snapshot", "major": false} -500 2 16w39b \N 2016-09-29 14:39:39+00 {"type": "snapshot", "major": false} -501 2 16w39a \N 2016-09-28 13:32:06+00 {"type": "snapshot", "major": false} -510 2 1.10 \N 2016-06-08 13:06:18+00 {"type": "release", "major": false} -512 2 1.10-pre1 \N 2016-06-02 14:45:16+00 {"type": "snapshot", "major": false} -513 2 16w21b \N 2016-05-26 12:47:22+00 {"type": "snapshot", "major": false} -515 2 16w20a \N 2016-05-18 12:45:14+00 {"type": "snapshot", "major": false} -516 2 1.9.4 \N 2016-05-10 10:17:16+00 {"type": "release", "major": false} -518 2 1.9.3-pre3 \N 2016-05-03 09:28:11+00 {"type": "snapshot", "major": false} -519 2 1.9.3-pre2 \N 2016-04-27 13:33:20+00 {"type": "snapshot", "major": false} -392 2 18w47b \N 2018-11-23 10:46:41+00 {"type": "snapshot", "major": false} -395 2 18w45a \N 2018-11-07 14:40:06+00 {"type": "snapshot", "major": false} -396 2 18w44a \N 2018-10-31 15:29:16+00 {"type": "snapshot", "major": false} -398 2 18w43b \N 2018-10-24 15:02:30+00 {"type": "snapshot", "major": false} -399 2 18w43a \N 2018-10-24 10:52:16+00 {"type": "snapshot", "major": false} -401 2 1.13.2-pre2 \N 2018-10-18 14:46:12+00 {"type": "snapshot", "major": false} -402 2 1.13.2-pre1 \N 2018-10-16 13:40:58+00 {"type": "snapshot", "major": false} -404 2 1.13.1-pre2 \N 2018-08-20 13:52:09+00 {"type": "snapshot", "major": false} -405 2 1.13.1-pre1 \N 2018-08-16 13:08:44+00 {"type": "snapshot", "major": false} -407 2 18w32a \N 2018-08-08 13:16:57+00 {"type": "snapshot", "major": false} -408 2 18w31a \N 2018-08-01 12:54:44+00 {"type": "snapshot", "major": false} -410 2 18w30a \N 2018-07-25 14:29:31+00 {"type": "snapshot", "major": false} -411 2 1.13 \N 2018-07-18 15:11:46+00 {"type": "release", "major": false} -413 2 1.13-pre9 \N 2018-07-16 14:17:42+00 {"type": "snapshot", "major": false} -414 2 1.13-pre8 \N 2018-07-13 11:45:00+00 {"type": "snapshot", "major": false} -416 2 1.13-pre6 \N 2018-07-04 12:36:00+00 {"type": "snapshot", "major": false} -419 2 1.13-pre3 \N 2018-06-21 12:57:11+00 {"type": "snapshot", "major": false} -425 2 18w21b \N 2018-05-25 10:09:09+00 {"type": "snapshot", "major": false} -429 2 18w20a \N 2018-05-15 14:02:25+00 {"type": "snapshot", "major": false} -449 2 18w03b \N 2018-01-17 15:09:14+00 {"type": "snapshot", "major": false} -465 2 1.12.2-pre2 \N 2017-09-15 08:21:17+00 {"type": "snapshot", "major": false} -504 2 16w35a \N 2016-09-01 13:13:38+00 {"type": "snapshot", "major": false} -506 2 16w32b \N 2016-08-11 14:34:29+00 {"type": "snapshot", "major": false} -483 2 17w16a \N 2017-04-20 13:58:35+00 {"type": "snapshot", "major": false} -589 2 15w31c \N 2015-07-31 13:45:08+00 {"type": "snapshot", "major": false} -622 2 14w33b \N 2014-08-15 16:23:51+00 {"type": "snapshot", "major": false} -526 2 1.9.1 \N 2016-03-30 13:43:07+00 {"type": "release", "major": false} -556 2 15w43c \N 2015-10-23 15:35:55+00 {"type": "snapshot", "major": false} -559 2 15w42a \N 2015-10-14 13:25:14+00 {"type": "snapshot", "major": false} -625 2 14w32c \N 2014-08-08 14:11:20+00 {"type": "snapshot", "major": false} -493 2 1.11-pre1 \N 2016-11-08 13:42:50+00 {"type": "snapshot", "major": false} -529 2 1.9.1-pre1 \N 2016-03-09 16:27:29+00 {"type": "snapshot", "major": false} -652 2 14w19a \N 2014-05-08 14:24:19+00 {"type": "snapshot", "major": false} -655 2 14w17a \N 2014-04-24 15:44:49+00 {"type": "snapshot", "major": false} -658 2 1.7.8 \N 2014-04-09 07:58:16+00 {"type": "release", "major": false} -661 2 14w11a \N 2014-03-13 14:02:50+00 {"type": "snapshot", "major": false} -496 2 16w42a \N 2016-10-19 11:17:47+00 {"type": "snapshot", "major": false} -499 2 16w39c \N 2016-09-30 14:11:48+00 {"type": "snapshot", "major": false} -502 2 16w38a \N 2016-09-20 12:40:49+00 {"type": "snapshot", "major": false} -532 2 1.9-pre3 \N 2016-02-24 15:52:36+00 {"type": "snapshot", "major": false} -535 2 16w07b \N 2016-02-16 15:22:39+00 {"type": "snapshot", "major": false} -562 2 15w40b \N 2015-09-30 14:13:54+00 {"type": "snapshot", "major": false} -565 2 15w39b \N 2015-09-21 15:09:52+00 {"type": "snapshot", "major": false} -592 2 1.8.8 \N 2015-07-27 10:31:28+00 {"type": "release", "major": false} -595 2 1.8.5 \N 2015-05-22 11:15:28+00 {"type": "release", "major": false} -601 2 1.8.2-pre6 \N 2015-01-30 11:58:24+00 {"type": "snapshot", "major": false} -628 2 14w31a \N 2014-07-30 15:38:05+00 {"type": "snapshot", "major": false} -631 2 14w30a \N 2014-07-23 13:15:42+00 {"type": "snapshot", "major": false} -604 2 1.8.2-pre3 \N 2015-01-15 16:44:33+00 {"type": "snapshot", "major": false} -505 2 16w33a \N 2016-08-17 12:48:57+00 {"type": "snapshot", "major": false} -634 2 14w28b \N 2014-07-10 14:28:48+00 {"type": "snapshot", "major": false} -664 2 14w10c \N 2014-03-07 13:49:55+00 {"type": "snapshot", "major": false} -670 2 14w06b \N 2014-02-06 17:30:42+00 {"type": "snapshot", "major": false} -508 2 1.10.2 \N 2016-06-23 09:17:32+00 {"type": "release", "major": false} -538 2 16w05b \N 2016-02-04 15:28:02+00 {"type": "snapshot", "major": false} -568 2 15w38a \N 2015-09-16 14:22:31+00 {"type": "snapshot", "major": false} -571 2 15w36c \N 2015-09-02 16:07:22+00 {"type": "snapshot", "major": false} -511 2 1.10-pre2 \N 2016-06-07 14:56:34+00 {"type": "snapshot", "major": false} -514 2 16w21a \N 2016-05-25 13:12:09+00 {"type": "snapshot", "major": false} -541 2 16w03a \N 2016-01-20 14:29:24+00 {"type": "snapshot", "major": false} -607 2 1.8.1 \N 2014-11-24 14:13:31+00 {"type": "release", "major": false} -610 2 1.8.1-pre3 \N 2014-10-23 12:59:42+00 {"type": "snapshot", "major": false} -637 2 14w27a \N 2014-07-02 16:07:20+00 {"type": "snapshot", "major": false} -475 2 1.12-pre3 \N 2017-05-17 14:09:18+00 {"type": "snapshot", "major": false} -574 2 15w35e \N 2015-08-28 18:14:02+00 {"type": "snapshot", "major": false} -577 2 15w35b \N 2015-08-24 15:39:18+00 {"type": "snapshot", "major": false} -640 2 14w26a \N 2014-06-25 13:59:27+00 {"type": "snapshot", "major": false} -643 2 14w21b \N 2014-05-22 15:17:55+00 {"type": "snapshot", "major": false} -646 2 14w20a \N 2014-05-15 14:01:20+00 {"type": "snapshot", "major": false} -649 2 1.7.10-pre3 \N 2014-05-14 15:29:23+00 {"type": "snapshot", "major": false} -673 2 14w05a \N 2014-01-30 15:32:41+00 {"type": "snapshot", "major": false} -517 2 1.9.3 \N 2016-05-10 08:33:35+00 {"type": "release", "major": false} -580 2 15w34c \N 2015-08-21 12:45:20+00 {"type": "snapshot", "major": false} -583 2 15w33c \N 2015-08-14 13:10:46+00 {"type": "snapshot", "major": false} -613 2 1.8 \N 2014-09-02 08:24:35+00 {"type": "release", "major": false} -478 2 17w18b \N 2017-05-04 13:40:22+00 {"type": "snapshot", "major": false} -481 2 17w17a \N 2017-04-26 13:48:23+00 {"type": "snapshot", "major": false} -484 2 17w15a \N 2017-04-12 09:30:50+00 {"type": "snapshot", "major": false} -523 2 16w14a \N 2016-04-07 12:47:51+00 {"type": "snapshot", "major": false} -544 2 15w51a \N 2015-12-17 14:02:37+00 {"type": "snapshot", "major": false} -547 2 1.8.9 \N 2015-12-03 09:24:39+00 {"type": "release", "major": false} -550 2 15w47b \N 2015-11-19 14:48:03+00 {"type": "snapshot", "major": false} -553 2 15w45a \N 2015-11-05 13:04:07+00 {"type": "snapshot", "major": false} -586 2 15w32c \N 2015-08-07 14:08:17+00 {"type": "snapshot", "major": false} -616 2 1.8-pre1 \N 2014-08-21 13:56:26+00 {"type": "snapshot", "major": false} -619 2 14w34b \N 2014-08-18 15:14:28+00 {"type": "snapshot", "major": false} -676 2 14w03b \N 2014-01-16 16:36:19+00 {"type": "snapshot", "major": false} -679 2 14w02b \N 2014-01-09 15:45:55+00 {"type": "snapshot", "major": false} -487 2 17w13a \N 2017-03-30 09:32:19+00 {"type": "snapshot", "major": false} -490 2 1.11.1 \N 2016-12-20 14:05:34+00 {"type": "release", "major": false} -641 2 14w25b \N 2014-06-19 12:29:48+00 {"type": "snapshot", "major": false} -525 2 1.9.2 \N 2016-03-30 15:23:55+00 {"type": "release", "major": false} -662 2 1.7.6-pre2 \N 2014-03-08 11:00:01+00 {"type": "snapshot", "major": false} -545 2 15w50a \N 2015-12-09 15:35:57+00 {"type": "snapshot", "major": false} -621 2 14w33c \N 2014-08-15 18:00:26+00 {"type": "snapshot", "major": false} -663 2 1.7.6-pre1 \N 2014-03-08 11:00:00+00 {"type": "snapshot", "major": false} -527 2 1.9.1-pre3 \N 2016-03-11 09:20:36+00 {"type": "snapshot", "major": false} -665 2 14w10b \N 2014-03-06 16:25:39+00 {"type": "snapshot", "major": false} -528 2 1.9.1-pre2 \N 2016-03-10 15:06:03+00 {"type": "snapshot", "major": false} -623 2 14w33a \N 2014-08-13 15:08:14+00 {"type": "snapshot", "major": false} -624 2 14w32d \N 2014-08-08 15:13:41+00 {"type": "snapshot", "major": false} -626 2 14w32b \N 2014-08-07 14:45:17+00 {"type": "snapshot", "major": false} -666 2 14w10a \N 2014-03-06 14:23:04+00 {"type": "snapshot", "major": false} -668 2 1.7.5 \N 2014-02-26 09:22:17+00 {"type": "release", "major": false} -672 2 14w05b \N 2014-01-31 14:05:50+00 {"type": "snapshot", "major": false} -530 2 1.9 \N 2016-02-29 13:49:54+00 {"type": "release", "major": false} -531 2 1.9-pre4 \N 2016-02-26 15:21:11+00 {"type": "snapshot", "major": false} -533 2 1.9-pre2 \N 2016-02-18 17:41:00+00 {"type": "snapshot", "major": false} -534 2 1.9-pre1 \N 2016-02-17 15:23:19+00 {"type": "snapshot", "major": false} -536 2 16w07a \N 2016-02-15 15:48:46+00 {"type": "snapshot", "major": false} -537 2 16w06a \N 2016-02-10 15:06:41+00 {"type": "snapshot", "major": false} -627 2 14w32a \N 2014-08-06 14:01:16+00 {"type": "snapshot", "major": false} -629 2 14w30c \N 2014-07-24 14:39:09+00 {"type": "snapshot", "major": false} -674 2 14w04b \N 2014-01-24 15:48:46+00 {"type": "snapshot", "major": false} -675 2 14w04a \N 2014-01-23 15:26:13+00 {"type": "snapshot", "major": false} -677 2 14w03a \N 2014-01-16 14:45:13+00 {"type": "snapshot", "major": false} -539 2 16w05a \N 2016-02-03 15:48:38+00 {"type": "snapshot", "major": false} -540 2 16w04a \N 2016-01-28 15:37:24+00 {"type": "snapshot", "major": false} -630 2 14w30b \N 2014-07-23 15:03:03+00 {"type": "snapshot", "major": false} -632 2 14w29b \N 2014-07-16 17:27:40+00 {"type": "snapshot", "major": false} -633 2 14w29a \N 2014-07-16 15:18:17+00 {"type": "snapshot", "major": false} -635 2 14w28a \N 2014-07-09 15:42:36+00 {"type": "snapshot", "major": false} -636 2 14w27b \N 2014-07-02 18:34:56+00 {"type": "snapshot", "major": false} -678 2 14w02c \N 2014-01-10 15:42:36+00 {"type": "snapshot", "major": false} -542 2 16w02a \N 2016-01-13 15:15:16+00 {"type": "snapshot", "major": false} -603 2 1.8.2-pre4 \N 2015-01-16 14:19:59+00 {"type": "snapshot", "major": false} -638 2 14w26c \N 2014-06-26 15:05:03+00 {"type": "snapshot", "major": false} -642 2 14w25a \N 2014-06-18 15:52:28+00 {"type": "snapshot", "major": false} -644 2 14w21a \N 2014-05-22 14:44:33+00 {"type": "snapshot", "major": false} -645 2 14w20b \N 2014-05-15 16:47:21+00 {"type": "snapshot", "major": false} -680 2 14w02a \N 2014-01-09 14:44:41+00 {"type": "snapshot", "major": false} -605 2 1.8.2-pre2 \N 2015-01-15 15:07:31+00 {"type": "snapshot", "major": false} -606 2 1.8.2-pre1 \N 2014-12-18 11:29:41+00 {"type": "snapshot", "major": false} -608 2 1.8.1-pre5 \N 2014-11-19 14:30:48+00 {"type": "snapshot", "major": false} -609 2 1.8.1-pre4 \N 2014-11-06 14:10:50+00 {"type": "snapshot", "major": false} -647 2 1.7.10 \N 2014-05-14 17:29:23+00 {"type": "release", "major": false} -648 2 1.7.10-pre4 \N 2014-05-14 16:29:23+00 {"type": "snapshot", "major": false} -611 2 1.8.1-pre2 \N 2014-10-16 14:19:27+00 {"type": "snapshot", "major": false} -650 2 1.7.10-pre2 \N 2014-05-14 14:29:23+00 {"type": "snapshot", "major": false} -612 2 1.8.1-pre1 \N 2014-10-15 13:25:11+00 {"type": "snapshot", "major": false} -614 2 1.8-pre3 \N 2014-08-28 09:40:54+00 {"type": "snapshot", "major": false} -617 2 14w34d \N 2014-08-20 12:46:59+00 {"type": "snapshot", "major": false} -651 2 1.7.10-pre1 \N 2014-05-14 13:29:23+00 {"type": "snapshot", "major": false} -653 2 14w18b \N 2014-05-02 11:38:17+00 {"type": "snapshot", "major": false} -654 2 14w18a \N 2014-04-30 10:25:35+00 {"type": "snapshot", "major": false} -618 2 14w34c \N 2014-08-19 15:31:24+00 {"type": "snapshot", "major": false} -657 2 1.7.9 \N 2014-04-14 13:29:23+00 {"type": "release", "major": false} -620 2 14w34a \N 2014-08-18 14:14:11+00 {"type": "snapshot", "major": false} -639 2 14w26b \N 2014-06-25 15:08:39+00 {"type": "snapshot", "major": false} -659 2 1.7.7 \N 2014-04-09 07:52:16+00 {"type": "release", "major": false} -522 2 16w15a \N 2016-04-11 14:38:28+00 {"type": "snapshot", "major": false} -543 2 15w51b \N 2015-12-17 15:30:41+00 {"type": "snapshot", "major": false} -660 2 1.7.6 \N 2014-04-09 07:52:06+00 {"type": "release", "major": false} -710 2 1.6.2 \N 2013-07-05 13:09:02+00 {"type": "release", "major": false} -711 2 1.6.1 \N 2013-06-28 14:48:41+00 {"type": "release", "major": false} -713 2 13w26a \N 2013-06-24 16:06:06+00 {"type": "snapshot", "major": false} -714 2 13w25c \N 2013-06-20 15:23:37+00 {"type": "snapshot", "major": false} -716 2 13w25a \N 2013-06-17 14:08:06+00 {"type": "snapshot", "major": false} -717 2 13w24b \N 2013-06-14 12:19:13+00 {"type": "snapshot", "major": false} -719 2 13w23b \N 2013-06-08 00:32:01+00 {"type": "snapshot", "major": false} -720 2 13w23a \N 2013-06-07 16:04:20+00 {"type": "snapshot", "major": false} -722 2 13w21b \N 2013-05-27 08:50:42+00 {"type": "snapshot", "major": false} -723 2 13w21a \N 2013-05-23 15:38:28+00 {"type": "snapshot", "major": false} -725 2 13w18c \N 2013-05-03 09:19:35+00 {"type": "snapshot", "major": false} -726 2 13w18b \N 2013-05-02 17:12:25+00 {"type": "snapshot", "major": false} -728 2 13w17a \N 2013-04-25 15:50:00+00 {"type": "snapshot", "major": false} -729 2 1.5.2 \N 2013-04-25 15:45:00+00 {"type": "release", "major": false} -731 2 13w16a \N 2013-04-21 12:49:30+00 {"type": "snapshot", "major": false} -732 2 1.5.1 \N 2013-03-20 10:00:00+00 {"type": "release", "major": false} -734 2 1.4.7 \N 2012-12-27 22:00:00+00 {"type": "release", "major": false} -735 2 1.4.5 \N 2012-12-19 22:00:00+00 {"type": "release", "major": false} -737 2 1.4.4 \N 2012-12-13 22:00:00+00 {"type": "release", "major": false} -738 2 1.4.3 \N 2012-11-30 22:00:00+00 {"type": "snapshot", "major": false} -740 2 1.4.1 \N 2012-11-22 22:00:00+00 {"type": "snapshot", "major": false} -767 2 b1.4 \N 2011-03-30 22:00:00+00 {"type": "beta", "major": false} -768 2 b1.3_01 \N 2011-02-22 22:00:00+00 {"type": "beta", "major": false} -770 2 b1.2_02 \N 2011-01-20 22:00:00+00 {"type": "beta", "major": false} -771 2 b1.2_01 \N 2011-01-13 22:00:00+00 {"type": "beta", "major": false} -773 2 b1.1_02 \N 2010-12-21 22:00:01+00 {"type": "beta", "major": false} -774 2 b1.1_01 \N 2010-12-21 22:00:00+00 {"type": "beta", "major": false} -776 2 b1.0_01 \N 2010-12-19 22:00:01+00 {"type": "beta", "major": false} -777 2 b1.0 \N 2010-12-19 22:00:00+00 {"type": "beta", "major": false} -779 2 a1.2.5 \N 2010-11-30 22:00:00+00 {"type": "alpha", "major": false} -17022531 2 1.21.8 \N 2025-07-17 12:04:02+00 {"type": "release", "major": false} -121 2 1.20-pre6 \N 2023-05-25 12:22:00+00 {"type": "snapshot", "major": false} -135 2 1.19.4-rc3 \N 2023-03-13 10:03:11+00 {"type": "snapshot", "major": false} -211 2 1.18-pre8 \N 2021-11-24 14:57:32+00 {"type": "snapshot", "major": false} -213 2 1.18-pre6 \N 2021-11-22 17:09:05+00 {"type": "snapshot", "major": false} -263 2 20w46a \N 2020-11-11 15:30:32+00 {"type": "snapshot", "major": false} -281 2 1.16.1 \N 2020-06-24 10:31:40+00 {"type": "release", "major": false} -313 2 1.15.2-pre2 \N 2020-01-16 12:35:57+00 {"type": "snapshot", "major": false} -393 2 18w47a \N 2018-11-21 15:45:22+00 {"type": "snapshot", "major": false} -463 2 17w43a \N 2017-10-25 14:43:50+00 {"type": "snapshot", "major": false} -469 2 17w31a \N 2017-08-01 09:41:23+00 {"type": "snapshot", "major": false} -497 2 16w41a \N 2016-10-13 14:28:35+00 {"type": "snapshot", "major": false} -521 2 16w15b \N 2016-04-13 13:56:41+00 {"type": "snapshot", "major": false} -615 2 1.8-pre2 \N 2014-08-25 14:52:18+00 {"type": "snapshot", "major": false} -667 2 14w08a \N 2014-02-26 17:00:00+00 {"type": "snapshot", "major": false} -708 2 13w36b \N 2013-09-06 12:31:58+00 {"type": "snapshot", "major": false} -811 2 rd-132328 \N 2009-05-13 21:28:00+00 {"type": "alpha", "major": false} -594 2 1.8.6 \N 2015-05-25 10:31:19+00 {"type": "release", "major": false} -598 2 1.8.3 \N 2015-02-20 14:00:09+00 {"type": "release", "major": false} -656 2 14w11b \N 2014-04-14 14:36:19+00 {"type": "snapshot", "major": false} -690 2 13w47a \N 2013-11-21 15:59:58+00 {"type": "snapshot", "major": false} -730 2 13w16b \N 2013-04-23 21:51:22+00 {"type": "snapshot", "major": false} -733 2 1.5 \N 2013-03-06 22:00:00+00 {"type": "snapshot", "major": false} -736 2 1.4.6 \N 2012-12-19 22:00:01+00 {"type": "release", "major": false} -739 2 1.4.2 \N 2012-11-24 22:00:00+00 {"type": "release", "major": false} -742 2 1.3.2 \N 2012-08-15 22:00:00+00 {"type": "release", "major": false} -745 2 1.2.5 \N 2012-03-29 22:00:00+00 {"type": "release", "major": false} -748 2 1.2.2 \N 2012-02-29 22:00:01+00 {"type": "release", "major": false} -751 2 1.0 \N 2011-11-17 22:00:00+00 {"type": "release", "major": false} -754 2 b1.7.3 \N 2011-07-07 22:00:00+00 {"type": "beta", "major": false} -757 2 b1.6.6 \N 2011-05-30 22:00:00+00 {"type": "beta", "major": false} -760 2 b1.6.3 \N 2011-05-25 22:00:03+00 {"type": "beta", "major": false} -50 2 1.21.1-rc1 \N 2024-08-07 14:29:18+00 {"type": "snapshot", "major": false} -138 2 1.19.4-pre4 \N 2023-03-08 13:08:22+00 {"type": "snapshot", "major": false} -176 2 1.19-pre5 \N 2022-06-01 10:56:23+00 {"type": "snapshot", "major": false} -196 2 1.18.2-pre1 \N 2022-02-18 16:00:32+00 {"type": "snapshot", "major": false} -238 2 1.17-pre3 \N 2021-06-01 15:43:46+00 {"type": "snapshot", "major": false} -254 2 21w06a \N 2021-02-10 17:13:54+00 {"type": "snapshot", "major": false} -314 2 1.15.2-pre1 \N 2020-01-14 16:19:31+00 {"type": "snapshot", "major": false} -350 2 1.14.3-pre3 \N 2019-06-14 08:03:33+00 {"type": "snapshot", "major": false} -390 2 18w48b \N 2018-11-30 10:37:31+00 {"type": "snapshot", "major": false} -394 2 18w46a \N 2018-11-15 13:43:14+00 {"type": "snapshot", "major": false} -428 2 18w20b \N 2018-05-16 14:35:35+00 {"type": "snapshot", "major": false} -462 2 17w43b \N 2017-10-26 13:36:22+00 {"type": "snapshot", "major": false} -520 2 1.9.3-pre1 \N 2016-04-21 12:41:42+00 {"type": "snapshot", "major": false} -524 2 1.RV-Pre1 \N 2016-03-31 16:18:53+00 {"type": "snapshot", "major": false} -763 2 b1.6 \N 2011-05-25 22:00:00+00 {"type": "beta", "major": false} -766 2 b1.4_01 \N 2011-04-04 22:00:00+00 {"type": "beta", "major": false} -769 2 b1.3b \N 2011-02-21 22:00:00+00 {"type": "beta", "major": false} -772 2 b1.2 \N 2011-01-12 22:00:00+00 {"type": "beta", "major": false} -775 2 b1.0.2 \N 2010-12-20 22:00:00+00 {"type": "beta", "major": false} -778 2 a1.2.6 \N 2010-12-02 22:00:00+00 {"type": "alpha", "major": false} -781 2 a1.2.3_04 \N 2010-11-25 22:00:00+00 {"type": "alpha", "major": false} -784 2 a1.2.3 \N 2010-11-23 22:00:00+00 {"type": "alpha", "major": false} -787 2 a1.2.1_01 \N 2010-11-04 22:00:01+00 {"type": "alpha", "major": false} -790 2 a1.2.0_01 \N 2010-10-30 22:00:00+00 {"type": "alpha", "major": false} -793 2 a1.1.2 \N 2010-09-19 22:00:00+00 {"type": "alpha", "major": false} -796 2 a1.0.17_02 \N 2010-08-19 22:00:00+00 {"type": "alpha", "major": false} -799 2 a1.0.14 \N 2010-07-29 22:00:00+00 {"type": "alpha", "major": false} -802 2 a1.0.4 \N 2010-07-08 22:00:00+00 {"type": "alpha", "major": false} -805 2 c0.0.13a \N 2009-05-30 22:00:00+00 {"type": "alpha", "major": false} -808 2 rd-161348 \N 2009-05-16 11:48:00+00 {"type": "alpha", "major": false} -789 2 a1.2.0_02 \N 2010-11-03 22:00:00+00 {"type": "alpha", "major": false} -791 2 a1.2.0 \N 2010-10-29 22:00:00+00 {"type": "alpha", "major": false} -792 2 a1.1.2_01 \N 2010-09-22 22:00:00+00 {"type": "alpha", "major": false} -794 2 a1.1.0 \N 2010-09-12 22:00:00+00 {"type": "alpha", "major": false} -795 2 a1.0.17_04 \N 2010-08-22 22:00:00+00 {"type": "alpha", "major": false} -797 2 a1.0.16 \N 2010-08-11 22:00:00+00 {"type": "alpha", "major": false} -798 2 a1.0.15 \N 2010-08-03 22:00:00+00 {"type": "alpha", "major": false} -800 2 a1.0.11 \N 2010-07-22 22:00:00+00 {"type": "alpha", "major": false} -801 2 a1.0.5_01 \N 2010-07-12 22:00:00+00 {"type": "alpha", "major": false} -803 2 inf-20100618 \N 2010-06-15 22:00:00+00 {"type": "alpha", "major": false} -804 2 c0.30_01c \N 2009-12-21 22:00:00+00 {"type": "alpha", "major": false} -806 2 c0.0.13a_03 \N 2009-05-21 22:00:00+00 {"type": "alpha", "major": false} -807 2 c0.0.11a \N 2009-05-16 22:00:00+00 {"type": "alpha", "major": false} -809 2 rd-160052 \N 2009-05-15 22:52:00+00 {"type": "alpha", "major": false} -810 2 rd-20090515 \N 2009-05-14 22:00:00+00 {"type": "alpha", "major": false} -812 2 rd-132211 \N 2009-05-13 20:11:00+00 {"type": "alpha", "major": false} -11 2 1.21.5-pre1 \N 2025-03-11 12:49:44+00 {"type": "snapshot", "major": false} -12 2 25w10a \N 2025-03-05 13:11:13+00 {"type": "snapshot", "major": false} -1005 3 bukkit \N 2025-04-03 21:55:46.229944+00 \N -1006 3 bungeecord \N 2025-04-03 21:55:46.229944+00 \N -1007 3 canvas \N 2025-04-03 21:55:46.229944+00 \N -1008 3 datapack \N 2025-04-03 21:55:46.229944+00 \N -1009 3 fabric \N 2025-04-03 21:55:46.229944+00 \N -10010 3 folia \N 2025-04-03 21:55:46.229944+00 \N -10011 3 forge \N 2025-04-03 21:55:46.229944+00 \N -10012 3 iris \N 2025-04-03 21:55:46.229944+00 \N -10013 3 liteloader \N 2025-04-03 21:55:46.229944+00 \N -10014 3 neoforge \N 2025-04-03 21:55:46.229944+00 \N -\. -COPY public.loader_fields_loaders (loader_id, loader_field_id) FROM stdin; -1 3 -3 3 -2 3 -4 3 -5 3 -6 3 -22 3 -23 4 -23 3 -7 3 -9 3 -16 3 -17 3 -18 3 -19 3 -19 9 -20 3 -15 3 -21 3 -13 3 -10 3 -11 3 -8 3 -12 3 -14 3 -24 3 -25 3 -26 3 -27 3 -28 3 -29 3 -1 9 -3 9 -2 9 -4 9 -5 9 -6 9 -22 9 -23 9 -24 9 -25 9 -26 9 -27 9 -28 9 -29 9 -\. -- For some reason, the actual staging DB has different IDs for project types diff --git a/apps/labrinth/fixtures/minecraft-meta-seed-data-202508052143.sql b/apps/labrinth/fixtures/minecraft-meta-seed-data-202508052143.sql new file mode 100644 index 0000000000..e944886f7d --- /dev/null +++ b/apps/labrinth/fixtures/minecraft-meta-seed-data-202508052143.sql @@ -0,0 +1,868 @@ +-- Metadata-only Minecraft versions extracted from labrinth-seed-data-202508052143.sql. +-- Run against minecraft-meta after the 20261014_180018_initial migration, not Labrinth. +-- Legacy created timestamps are preserved as releaseTime and used as time because +-- upstream modification times are unavailable. alpha/beta map to old_alpha/old_beta. +-- The legacy major flag has no equivalent in the current metadata schema. +-- No upstream manifests, URLs, asset indexes, or checksums exist in the source. +-- Required JSON fields use non-playable defaults: empty downloads/libraries/mainClass, +-- zero sizes/checksums/compliance/launcher version, and reserved .invalid URLs. +-- These rows support metadata listings only; generating real metadata is required +-- before installation/launch. Zero source hashes mark unavailable provenance. +-- Latest flags remain NULL (not false) to respect their unique indexes and avoid +-- asserting a latest version. Existing rows, including real manifests, are untouched. +BEGIN TRANSACTION; + +INSERT INTO public.minecraft_versions ( + name, catalog_entry, manifest, source_catalog_sha256, source_manifest_sha256, + is_latest_release, is_latest_snapshot +) +SELECT + name, + json_build_object( + 'id', name, 'type', version_type, 'url', 'https://metadata-only.invalid/manifest', + 'time', released_at, 'releaseTime', released_at, + 'sha1', repeat('0', 40), 'complianceLevel', 0 + ), + json_build_object( + 'id', name, 'type', version_type, 'time', released_at, 'releaseTime', released_at, + 'assetIndex', json_build_object( + 'id', '', 'sha1', repeat('0', 40), 'size', 0, 'totalSize', 0, + 'url', 'https://metadata-only.invalid/assets' + ), + 'assets', '', 'downloads', '{}'::json, 'libraries', '[]'::json, + 'mainClass', '', 'minimumLauncherVersion', 0 + ), + repeat('0', 64), repeat('0', 64), NULL::boolean, NULL::boolean +FROM (VALUES + ('1.7.2', 'release', '2013-10-25T13:00:00Z'), + ('1.7.1', 'snapshot', '2013-10-23T12:01:07Z'), + ('1.7', 'snapshot', '2013-10-22T15:04:05Z'), + ('13w43a', 'snapshot', '2013-10-21T16:34:47Z'), + ('13w42b', 'snapshot', '2013-10-18T16:34:08Z'), + ('13w42a', 'snapshot', '2013-10-17T18:33:05Z'), + ('13w41b', 'snapshot', '2013-10-11T15:09:17Z'), + ('13w41a', 'snapshot', '2013-10-10T14:21:43Z'), + ('13w39b', 'snapshot', '2013-09-27T12:15:58Z'), + ('13w39a', 'snapshot', '2013-09-26T15:11:19Z'), + ('13w38c', 'snapshot', '2013-09-20T15:11:34Z'), + ('13w38b', 'snapshot', '2013-09-20T13:45:40Z'), + ('13w38a', 'snapshot', '2013-09-19T16:34:21Z'), + ('25w32a', 'snapshot', '2025-08-05T12:21:45Z'), + ('25w31a', 'snapshot', '2025-07-29T11:29:33Z'), + ('1.6.4', 'release', '2013-09-19T15:52:37Z'), + ('13w37b', 'snapshot', '2013-09-13T10:54:42Z'), + ('1.21.7-rc1', 'snapshot', '2025-06-25T12:41:59Z'), + ('1.21.5-rc1', 'snapshot', '2025-03-20T13:45:48Z'), + ('21w39a', 'snapshot', '2021-09-29T16:27:05Z'), + ('1.17.1-pre2', 'snapshot', '2021-06-29T15:14:12Z'), + ('20w45a', 'snapshot', '2020-11-04T16:42:00Z'), + ('20w13b', 'snapshot', '2020-03-26T13:00:34Z'), + ('1.6.3', 'snapshot', '2013-09-13T10:54:41Z'), + ('1.15.2', 'release', '2020-01-17T10:03:52Z'), + ('1.14.2-pre1', 'snapshot', '2019-05-16T15:40:25Z'), + ('1.13-pre4', 'snapshot', '2018-06-26T13:00:55Z'), + ('18w10d', 'snapshot', '2018-03-09T15:19:12Z'), + ('18w06a', 'snapshot', '2018-02-09T12:09:55Z'), + ('1.8.2-pre7', 'snapshot', '2015-02-16T13:01:35Z'), + ('1.8.2-pre5', 'snapshot', '2015-01-26T15:03:24Z'), + ('14w07a', 'snapshot', '2014-02-14T11:05:07Z'), + ('14w06a', 'snapshot', '2014-02-06T14:30:17Z'), + ('1.7.4', 'release', '2013-12-09T12:28:10Z'), + ('1.7.3', 'release', '2013-12-06T13:55:34Z'), + ('13w49a', 'snapshot', '2013-12-05T14:34:41Z'), + ('13w47c', 'snapshot', '2013-11-21T17:10:33Z'), + ('13w47b', 'snapshot', '2013-11-21T16:57:41Z'), + ('21w38a', 'snapshot', '2021-09-23T14:36:06Z'), + ('18w10c', 'snapshot', '2018-03-08T15:29:23Z'), + ('1.12-pre6', 'snapshot', '2017-05-29T11:45:12Z'), + ('13w48b', 'snapshot', '2013-11-26T18:36:08Z'), + ('13w48a', 'snapshot', '2013-11-25T16:53:39Z'), + ('13w47e', 'snapshot', '2013-11-22T15:16:38Z'), + ('13w47d', 'snapshot', '2013-11-22T13:51:15Z'), + ('1.3.1', 'release', '2012-07-31T22:00:00Z'), + ('1.3', 'snapshot', '2012-07-25T22:00:00Z'), + ('1.2.4', 'release', '2012-03-21T22:00:00Z'), + ('1.2.3', 'release', '2012-03-01T22:00:00Z'), + ('1.2.1', 'release', '2012-02-29T22:00:00Z'), + ('1.1', 'release', '2012-01-11T22:00:00Z'), + ('b1.8.1', 'old_beta', '2011-09-18T22:00:00Z'), + ('b1.8', 'old_beta', '2011-09-14T22:00:00Z'), + ('b1.7.2', 'old_beta', '2011-06-30T22:00:00Z'), + ('b1.7', 'old_beta', '2011-06-29T22:00:00Z'), + ('b1.6.5', 'old_beta', '2011-05-27T22:00:00Z'), + ('13w37a', 'snapshot', '2013-09-12T14:23:14Z'), + ('13w36a', 'snapshot', '2013-09-05T13:05:40Z'), + ('1.6', 'snapshot', '2013-06-25T13:08:56Z'), + ('13w25b', 'snapshot', '2013-06-18T15:13:27Z'), + ('13w24a', 'snapshot', '2013-06-13T15:32:23Z'), + ('13w22a', 'snapshot', '2013-05-30T14:38:40Z'), + ('13w19a', 'snapshot', '2013-05-10T14:48:02Z'), + ('13w18a', 'snapshot', '2013-05-02T15:45:59Z'), + ('1.4', 'snapshot', '2012-11-18T22:00:00Z'), + ('b1.6.4', 'old_beta', '2011-05-25T22:00:04Z'), + ('b1.6.2', 'old_beta', '2011-05-25T22:00:02Z'), + ('b1.6.1', 'old_beta', '2011-05-25T22:00:01Z'), + ('b1.5_01', 'old_beta', '2011-04-19T22:00:00Z'), + ('b1.5', 'old_beta', '2011-04-18T22:00:00Z'), + ('24w09a', 'snapshot', '2024-02-28T12:38:12Z'), + ('23w12a', 'snapshot', '2023-03-22T13:28:42Z'), + ('23w35a', 'snapshot', '2023-08-30T11:24:35Z'), + ('24w05b', 'snapshot', '2024-02-01T12:55:14Z'), + ('24w03b', 'snapshot', '2024-01-18T12:42:37Z'), + ('23w51a', 'snapshot', '2023-12-18T13:36:46Z'), + ('1.20.3', 'release', '2023-12-04T12:10:32Z'), + ('1.20.3-pre3', 'snapshot', '2023-11-27T14:24:36Z'), + ('23w46a', 'snapshot', '2023-11-16T14:11:33Z'), + ('23w43b', 'snapshot', '2023-10-26T13:46:16Z'), + ('23w41a', 'snapshot', '2023-10-11T12:32:46Z'), + ('1.20.2-rc2', 'snapshot', '2023-09-18T12:34:57Z'), + ('1.20.2-pre3', 'snapshot', '2023-09-12T12:15:08Z'), + ('23w31a', 'snapshot', '2023-08-01T10:03:13Z'), + ('1.20', 'release', '2023-06-02T08:36:17Z'), + ('1.20-pre3', 'snapshot', '2023-05-19T11:39:46Z'), + ('23w18a', 'snapshot', '2023-05-03T11:29:26Z'), + ('1.19.4-rc2', 'snapshot', '2023-03-10T12:42:54Z'), + ('1.19.4-pre3', 'snapshot', '2023-03-01T14:11:05Z'), + ('23w07a', 'snapshot', '2023-02-15T14:32:29Z'), + ('23w04a', 'snapshot', '2023-01-24T15:19:06Z'), + ('1.19.3-rc3', 'snapshot', '2022-12-06T10:24:01Z'), + ('1.19.3-pre3', 'snapshot', '2022-11-29T14:28:08Z'), + ('22w46a', 'snapshot', '2022-11-16T13:32:50Z'), + ('22w43a', 'snapshot', '2022-10-26T11:55:59Z'), + ('1.19.2-rc2', 'snapshot', '2022-08-04T15:19:44Z'), + ('1.19.1-rc3', 'snapshot', '2022-07-26T15:34:35Z'), + ('1.19.1-pre5', 'snapshot', '2022-07-15T11:51:44Z'), + ('1.19.1-pre2', 'snapshot', '2022-06-30T15:57:20Z'), + ('1.19-rc1', 'snapshot', '2022-06-02T12:12:52Z'), + ('1.19-pre3', 'snapshot', '2022-05-25T09:56:47Z'), + ('22w19a', 'snapshot', '2022-05-12T15:36:11Z'), + ('22w16b', 'snapshot', '2022-04-20T17:25:32Z'), + ('22w14a', 'snapshot', '2022-04-06T13:37:12Z'), + ('22w12a', 'snapshot', '2022-03-24T16:15:02Z'), + ('1.18.2-rc1', 'snapshot', '2022-02-25T13:25:40Z'), + ('22w05a', 'snapshot', '2022-02-02T16:08:39Z'), + ('1.18.1-rc3', 'snapshot', '2021-12-10T03:36:38Z'), + ('1.18.1-pre1', 'snapshot', '2021-12-03T13:45:38Z'), + ('1.18-rc3', 'snapshot', '2021-11-26T15:51:56Z'), + ('1.21.8-rc1', 'snapshot', '2025-07-15T13:42:50Z'), + ('1.21.7', 'release', '2025-06-30T09:32:16Z'), + ('1.21.7-rc2', 'snapshot', '2025-06-26T13:59:20Z'), + ('1.21.6', 'release', '2025-06-17T11:10:28Z'), + ('1.21.6-pre4', 'snapshot', '2025-06-10T12:22:36Z'), + ('1.21.6-pre2', 'snapshot', '2025-06-02T13:40:47Z'), + ('25w21a', 'snapshot', '2025-05-20T12:09:09Z'), + ('25w19a', 'snapshot', '2025-05-06T12:57:57Z'), + ('25w17a', 'snapshot', '2025-04-22T12:51:30Z'), + ('1.21.5-pre2', 'snapshot', '2025-03-12T12:36:02Z'), + ('25w09a', 'snapshot', '2025-02-26T15:16:02Z'), + ('25w07a', 'snapshot', '2025-02-13T12:55:37Z'), + ('25w05a', 'snapshot', '2025-01-29T14:03:54Z'), + ('25w03a', 'snapshot', '2025-01-15T14:28:04Z'), + ('1.21.4', 'release', '2024-12-03T10:12:57Z'), + ('1.21.4-rc2', 'snapshot', '2024-11-29T10:33:13Z'), + ('1.21.4-pre3', 'snapshot', '2024-11-26T15:07:29Z'), + ('1.21.4-pre1', 'snapshot', '2024-11-20T13:45:00Z'), + ('24w45a', 'snapshot', '2024-11-06T13:31:58Z'), + ('1.21.3', 'release', '2024-10-23T12:28:15Z'), + ('1.21.2-rc2', 'snapshot', '2024-10-21T15:53:05Z'), + ('1.21.2-pre5', 'snapshot', '2024-10-16T13:30:35Z'), + ('1.21.2-pre3', 'snapshot', '2024-10-11T12:32:27Z'), + ('1.21.2-pre1', 'snapshot', '2024-10-08T13:22:12Z'), + ('24w39a', 'snapshot', '2024-09-25T13:08:41Z'), + ('24w37a', 'snapshot', '2024-09-11T13:01:31Z'), + ('24w35a', 'snapshot', '2024-08-28T12:25:10Z'), + ('24w33a', 'snapshot', '2024-08-15T12:39:34Z'), + ('1.21-rc1', 'snapshot', '2024-06-10T12:24:08Z'), + ('1.21-pre3', 'snapshot', '2024-06-05T08:51:44Z'), + ('1.21-pre1', 'snapshot', '2024-05-29T12:04:43Z'), + ('24w19a', 'snapshot', '2024-05-10T12:15:31Z'), + ('1.20.6-rc1', 'snapshot', '2024-04-26T10:12:17Z'), + ('1.20.5-rc2', 'snapshot', '2024-04-19T13:13:15Z'), + ('1.20.5-pre3', 'snapshot', '2024-04-16T11:57:30Z'), + ('24w14a', 'snapshot', '2024-04-03T11:49:39Z'), + ('24w12a', 'snapshot', '2024-03-20T14:38:37Z'), + ('23w14a', 'snapshot', '2023-04-05T12:05:17Z'), + ('22w24a', 'snapshot', '2022-06-15T16:21:49Z'), + ('25w20a', 'snapshot', '2025-05-13T11:46:28Z'), + ('a1.2.1', 'old_alpha', '2010-11-04T22:00:00Z'), + ('1.21.6-rc1', 'snapshot', '2025-06-12T12:04:37Z'), + ('1.21.6-pre1', 'snapshot', '2025-05-28T09:34:04Z'), + ('1.8.4', 'release', '2015-04-17T11:37:50Z'), + ('15w33b', 'snapshot', '2015-08-12T15:29:11Z'), + ('15w32b', 'snapshot', '2015-08-06T13:51:47Z'), + ('15w14a', 'snapshot', '2015-04-01T07:08:00Z'), + ('1.8.2', 'release', '2015-02-19T15:47:29Z'), + ('a1.2.4_01', 'old_alpha', '2010-11-29T22:00:00Z'), + ('a1.2.3_02', 'old_alpha', '2010-11-24T22:00:00Z'), + ('a1.2.3_01', 'old_alpha', '2010-11-23T22:00:01Z'), + ('a1.2.2b', 'old_alpha', '2010-11-09T22:00:01Z'), + ('a1.2.2a', 'old_alpha', '2010-11-09T22:00:00Z'), + ('1.21.6-pre3', 'snapshot', '2025-06-04T13:33:25Z'), + ('25w18a', 'snapshot', '2025-04-29T12:21:01Z'), + ('25w16a', 'snapshot', '2025-04-15T12:01:58Z'), + ('25w15a', 'snapshot', '2025-04-08T12:16:59Z'), + ('25w14craftmine', 'snapshot', '2025-04-01T15:50:09Z'), + ('1.21.5-rc2', 'snapshot', '2025-03-24T13:07:03Z'), + ('1.21.5-pre3', 'snapshot', '2025-03-18T13:58:30Z'), + ('25w09b', 'snapshot', '2025-02-27T11:07:08Z'), + ('25w08a', 'snapshot', '2025-02-19T13:41:43Z'), + ('25w06a', 'snapshot', '2025-02-05T12:41:17Z'), + ('25w04a', 'snapshot', '2025-01-22T13:14:44Z'), + ('25w02a', 'snapshot', '2025-01-08T13:42:18Z'), + ('1.21.4-rc3', 'snapshot', '2024-11-29T17:02:53Z'), + ('1.21.4-pre2', 'snapshot', '2024-11-25T13:18:35Z'), + ('24w46a', 'snapshot', '2024-11-13T13:12:38Z'), + ('24w44a', 'snapshot', '2024-10-30T12:53:55Z'), + ('1.21.2', 'release', '2024-10-22T09:58:55Z'), + ('1.21.2-rc1', 'snapshot', '2024-10-17T12:43:18Z'), + ('1.21.2-pre4', 'snapshot', '2024-10-15T11:59:11Z'), + ('1.21.2-pre2', 'snapshot', '2024-10-10T12:59:14Z'), + ('24w40a', 'snapshot', '2024-10-02T13:15:42Z'), + ('24w38a', 'snapshot', '2024-09-18T12:32:07Z'), + ('24w36a', 'snapshot', '2024-09-04T12:44:12Z'), + ('24w34a', 'snapshot', '2024-08-21T14:14:13Z'), + ('1.21.1', 'release', '2024-08-08T12:24:45Z'), + ('1.21', 'release', '2024-06-13T08:24:03Z'), + ('1.21-pre4', 'snapshot', '2024-06-07T12:00:15Z'), + ('24w21a', 'snapshot', '2024-05-22T14:18:26Z'), + ('24w20a', 'snapshot', '2024-05-15T12:00:35Z'), + ('24w19b', 'snapshot', '2024-05-10T14:32:42Z'), + ('24w18a', 'snapshot', '2024-05-03T12:08:27Z'), + ('15w49b', 'snapshot', '2015-12-03T15:23:22Z'), + ('15w49a', 'snapshot', '2015-12-02T15:09:37Z'), + ('15w47c', 'snapshot', '2015-11-20T12:46:56Z'), + ('15w47a', 'snapshot', '2015-11-18T15:53:41Z'), + ('15w46a', 'snapshot', '2015-11-12T12:11:47Z'), + ('15w44b', 'snapshot', '2015-10-30T11:23:17Z'), + ('15w44a', 'snapshot', '2015-10-28T15:09:36Z'), + ('15w43a', 'snapshot', '2015-10-21T15:28:52Z'), + ('15w41b', 'snapshot', '2015-10-07T14:07:26Z'), + ('15w41a', 'snapshot', '2015-10-07T13:19:53Z'), + ('15w40a', 'snapshot', '2015-09-30T13:13:54Z'), + ('15w39c', 'snapshot', '2015-09-23T13:13:54Z'), + ('15w39a', 'snapshot', '2015-09-21T13:16:32Z'), + ('15w38b', 'snapshot', '2015-09-17T14:22:31Z'), + ('15w37a', 'snapshot', '2015-09-10T14:22:31Z'), + ('15w36d', 'snapshot', '2015-09-04T14:22:31Z'), + ('15w36b', 'snapshot', '2015-09-02T15:36:25Z'), + ('15w36a', 'snapshot', '2015-09-02T14:46:40Z'), + ('15w35d', 'snapshot', '2015-08-28T16:25:35Z'), + ('15w35c', 'snapshot', '2015-08-28T11:21:00Z'), + ('15w35a', 'snapshot', '2015-08-24T14:19:31Z'), + ('15w34a', 'snapshot', '2015-08-19T12:56:01Z'), + ('15w32a', 'snapshot', '2015-08-05T12:22:42Z'), + ('15w31b', 'snapshot', '2015-07-30T13:38:32Z'), + ('15w31a', 'snapshot', '2015-07-29T13:24:33Z'), + ('1.8.7', 'release', '2015-06-05T10:10:44Z'), + ('1.21.4-rc1', 'snapshot', '2024-11-28T10:19:01Z'), + ('15w34d', 'snapshot', '2015-08-21T15:27:55Z'), + ('15w34b', 'snapshot', '2015-08-20T14:00:03Z'), + ('15w33a', 'snapshot', '2015-08-12T14:05:07Z'), + ('1.21.5', 'release', '2025-03-25T12:14:58Z'), + ('1.21-pre2', 'snapshot', '2024-05-31T12:44:56Z'), + ('15w43b', 'snapshot', '2015-10-22T14:11:58Z'), + ('24w21b', 'snapshot', '2024-05-22T16:25:41Z'), + ('1.20.6', 'release', '2024-04-29T12:40:45Z'), + ('1.20.5', 'release', '2024-04-23T11:54:12Z'), + ('1.20.5-rc3', 'snapshot', '2024-04-22T13:42:34Z'), + ('1.20.5-rc1', 'snapshot', '2024-04-18T11:45:40Z'), + ('1.20.5-pre4', 'snapshot', '2024-04-17T11:56:02Z'), + ('1.20.5-pre2', 'snapshot', '2024-04-15T12:36:05Z'), + ('1.20.5-pre1', 'snapshot', '2024-04-10T12:44:25Z'), + ('24w14potato', 'snapshot', '2024-04-01T11:07:19Z'), + ('24w13a', 'snapshot', '2024-03-27T14:30:20Z'), + ('24w11a', 'snapshot', '2024-03-14T14:21:33Z'), + ('24w10a', 'snapshot', '2024-03-06T10:37:35Z'), + ('24w07a', 'snapshot', '2024-02-14T12:51:01Z'), + ('24w06a', 'snapshot', '2024-02-07T14:47:18Z'), + ('24w05a', 'snapshot', '2024-01-31T13:05:26Z'), + ('24w04a', 'snapshot', '2024-01-24T13:42:45Z'), + ('23w44a', 'snapshot', '2023-11-01T12:30:52Z'), + ('23w43a', 'snapshot', '2023-10-25T13:34:37Z'), + ('23w42a', 'snapshot', '2023-10-18T11:37:28Z'), + ('23w40a', 'snapshot', '2023-10-04T12:48:53Z'), + ('1.20.2', 'release', '2023-09-20T09:02:57Z'), + ('1.20.2-rc1', 'snapshot', '2023-09-15T13:10:30Z'), + ('1.20.2-pre4', 'snapshot', '2023-09-13T15:06:51Z'), + ('1.20.2-pre2', 'snapshot', '2023-09-07T12:42:32Z'), + ('1.20.2-pre1', 'snapshot', '2023-09-05T12:06:20Z'), + ('23w33a', 'snapshot', '2023-08-17T11:39:08Z'), + ('23w32a', 'snapshot', '2023-08-09T12:14:25Z'), + ('1.20.1', 'release', '2023-06-12T13:25:51Z'), + ('1.20.1-rc1', 'snapshot', '2023-06-09T14:15:49Z'), + ('1.20-rc1', 'snapshot', '2023-05-31T12:33:33Z'), + ('1.20-pre7', 'snapshot', '2023-05-29T13:44:34Z'), + ('1.20-pre5', 'snapshot', '2023-05-23T12:22:52Z'), + ('1.20-pre4', 'snapshot', '2023-05-19T13:13:45Z'), + ('1.20-pre2', 'snapshot', '2023-05-16T11:34:54Z'), + ('1.20-pre1', 'snapshot', '2023-05-10T12:19:34Z'), + ('23w17a', 'snapshot', '2023-04-26T12:09:48Z'), + ('23w16a', 'snapshot', '2023-04-20T11:55:19Z'), + ('23w13a_or_b', 'snapshot', '2023-04-01T12:52:18Z'), + ('23w13a', 'snapshot', '2023-03-29T13:54:16Z'), + ('1.19.4', 'release', '2023-03-14T12:56:18Z'), + ('24w03a', 'snapshot', '2024-01-17T13:19:20Z'), + ('23w51b', 'snapshot', '2023-12-18T15:39:14Z'), + ('1.20.4', 'release', '2023-12-07T12:56:20Z'), + ('1.20.4-rc1', 'snapshot', '2023-12-06T14:38:01Z'), + ('1.20.3-rc1', 'snapshot', '2023-11-30T13:41:45Z'), + ('1.20.3-pre4', 'snapshot', '2023-11-28T13:47:32Z'), + ('1.20.3-pre2', 'snapshot', '2023-11-22T12:21:26Z'), + ('1.20.3-pre1', 'snapshot', '2023-11-20T15:40:14Z'), + ('23w45a', 'snapshot', '2023-11-08T13:59:58Z'), + ('1.19.1-pre3', 'snapshot', '2022-07-06T14:50:46Z'), + ('1.19.1-rc1', 'snapshot', '2022-06-23T16:32:41Z'), + ('1.19.1-pre1', 'snapshot', '2022-06-21T17:13:59Z'), + ('1.19', 'release', '2022-06-07T09:42:18Z'), + ('21w19a', 'snapshot', '2021-05-12T11:19:15Z'), + ('21w18a', 'snapshot', '2021-05-05T15:24:35Z'), + ('21w05b', 'snapshot', '2021-02-04T15:09:29Z'), + ('1.19-rc2', 'snapshot', '2022-06-03T11:47:25Z'), + ('1.19-pre4', 'snapshot', '2022-05-30T14:43:01Z'), + ('1.19-pre2', 'snapshot', '2022-05-23T14:54:00Z'), + ('1.19-pre1', 'snapshot', '2022-05-18T13:51:54Z'), + ('22w18a', 'snapshot', '2022-05-04T14:41:35Z'), + ('22w16a', 'snapshot', '2022-04-20T14:37:07Z'), + ('22w15a', 'snapshot', '2022-04-13T15:41:17Z'), + ('22w13oneblockatatime', 'snapshot', '2022-04-01T11:56:58Z'), + ('22w13a', 'snapshot', '2022-03-31T14:53:25Z'), + ('1.18.2', 'release', '2022-02-28T10:42:45Z'), + ('22w03a', 'snapshot', '2022-01-19T16:04:59Z'), + ('1.18.1', 'release', '2021-12-10T08:23:00Z'), + ('1.18.1-rc2', 'snapshot', '2021-12-08T12:29:36Z'), + ('1.18.1-rc1', 'snapshot', '2021-12-07T15:52:47Z'), + ('1.18', 'release', '2021-11-30T09:16:29Z'), + ('1.18-rc4', 'snapshot', '2021-11-29T13:43:42Z'), + ('1.18-rc2', 'snapshot', '2021-11-26T10:02:04Z'), + ('1.18-rc1', 'snapshot', '2021-11-25T14:28:49Z'), + ('1.18-pre7', 'snapshot', '2021-11-23T16:37:41Z'), + ('1.18-pre4', 'snapshot', '2021-11-17T18:07:56Z'), + ('1.18-pre3', 'snapshot', '2021-11-17T16:04:25Z'), + ('1.18-pre1', 'snapshot', '2021-11-11T16:14:06Z'), + ('21w44a', 'snapshot', '2021-11-03T16:14:34Z'), + ('21w42a', 'snapshot', '2021-10-20T12:41:25Z'), + ('21w41a', 'snapshot', '2021-10-13T15:23:23Z'), + ('1.17.1', 'release', '2021-07-06T12:01:34Z'), + ('1.17.1-rc2', 'snapshot', '2021-07-05T12:58:01Z'), + ('1.17.1-pre3', 'snapshot', '2021-06-30T15:43:16Z'), + ('1.17', 'release', '2021-06-08T11:00:40Z'), + ('1.17-rc2', 'snapshot', '2021-06-07T11:46:28Z'), + ('1.17-pre5', 'snapshot', '2021-06-03T17:01:28Z'), + ('1.17-pre4', 'snapshot', '2021-06-02T16:15:43Z'), + ('1.17-pre2', 'snapshot', '2021-05-31T15:54:05Z'), + ('21w16a', 'snapshot', '2021-04-21T16:41:14Z'), + ('21w15a', 'snapshot', '2021-04-14T13:41:34Z'), + ('21w13a', 'snapshot', '2021-03-31T16:17:46Z'), + ('21w11a', 'snapshot', '2021-03-17T15:05:50Z'), + ('21w08b', 'snapshot', '2021-02-25T11:46:34Z'), + ('21w08a', 'snapshot', '2021-02-24T14:38:51Z'), + ('21w03a', 'snapshot', '2021-01-20T14:56:29Z'), + ('1.16.5', 'release', '2021-01-14T16:05:32Z'), + ('20w51a', 'snapshot', '2020-12-16T16:27:57Z'), + ('20w49a', 'snapshot', '2020-12-02T16:47:20Z'), + ('1.19.4-rc1', 'snapshot', '2023-03-09T14:35:50Z'), + ('1.19.4-pre2', 'snapshot', '2023-02-27T13:38:53Z'), + ('1.19.4-pre1', 'snapshot', '2023-02-22T16:00:34Z'), + ('23w06a', 'snapshot', '2023-02-08T15:00:04Z'), + ('23w05a', 'snapshot', '2023-02-01T14:20:33Z'), + ('23w03a', 'snapshot', '2023-01-18T13:10:31Z'), + ('1.19.3', 'release', '2022-12-07T08:17:18Z'), + ('1.19.3-rc2', 'snapshot', '2022-12-05T13:21:34Z'), + ('1.19.3-rc1', 'snapshot', '2022-12-01T13:45:18Z'), + ('1.19.3-pre2', 'snapshot', '2022-11-23T16:12:25Z'), + ('1.19.3-pre1', 'snapshot', '2022-11-22T13:59:37Z'), + ('22w45a', 'snapshot', '2022-11-09T14:30:16Z'), + ('22w44a', 'snapshot', '2022-11-02T13:15:43Z'), + ('22w42a', 'snapshot', '2022-10-19T09:34:22Z'), + ('1.19.2', 'release', '2022-08-05T11:57:05Z'), + ('1.19.2-rc1', 'snapshot', '2022-08-04T10:07:26Z'), + ('1.19.1', 'release', '2022-07-27T09:25:33Z'), + ('1.19.1-rc2', 'snapshot', '2022-07-21T16:25:50Z'), + ('1.19.1-pre6', 'snapshot', '2022-07-20T15:49:31Z'), + ('1.19.1-pre4', 'snapshot', '2022-07-08T11:41:59Z'), + ('22w17a', 'snapshot', '2022-04-27T15:54:15Z'), + ('22w11a', 'snapshot', '2022-03-16T15:55:38Z'), + ('1.17-pre1', 'snapshot', '2021-05-27T09:39:21Z'), + ('1.18.2-pre3', 'snapshot', '2022-02-23T15:23:12Z'), + ('1.18.2-pre2', 'snapshot', '2022-02-21T15:26:19Z'), + ('22w07a', 'snapshot', '2022-02-16T16:13:58Z'), + ('22w06a', 'snapshot', '2022-02-09T16:47:48Z'), + ('18w19b', 'snapshot', '2018-05-09T10:00:51Z'), + ('18w15a', 'snapshot', '2018-04-11T14:54:22Z'), + ('18w11a', 'snapshot', '2018-03-13T15:10:59Z'), + ('18w10b', 'snapshot', '2018-03-07T15:56:01Z'), + ('19w38b', 'snapshot', '2019-09-18T14:59:13Z'), + ('18w08b', 'snapshot', '2018-02-22T15:44:49Z'), + ('18w07b', 'snapshot', '2018-02-15T14:28:42Z'), + ('18w05a', 'snapshot', '2018-01-31T13:32:09Z'), + ('18w02a', 'snapshot', '2018-01-10T11:54:55Z'), + ('17w49b', 'snapshot', '2017-12-07T15:29:54Z'), + ('17w47b', 'snapshot', '2017-11-23T15:30:12Z'), + ('17w45b', 'snapshot', '2017-11-10T10:07:02Z'), + ('1.12.2-pre1', 'snapshot', '2017-09-13T13:33:31Z'), + ('1.18-pre5', 'snapshot', '2021-11-19T15:47:09Z'), + ('1.18-pre2', 'snapshot', '2021-11-16T17:04:48Z'), + ('21w43a', 'snapshot', '2021-10-27T14:38:55Z'), + ('21w40a', 'snapshot', '2021-10-07T11:17:50Z'), + ('21w37a', 'snapshot', '2021-09-15T16:04:30Z'), + ('1.17.1-rc1', 'snapshot', '2021-07-01T15:23:37Z'), + ('1.17.1-pre1', 'snapshot', '2021-06-18T12:24:40Z'), + ('1.17-rc1', 'snapshot', '2021-06-04T13:24:48Z'), + ('21w20a', 'snapshot', '2021-05-19T15:22:02Z'), + ('21w17a', 'snapshot', '2021-04-28T13:54:05Z'), + ('21w14a', 'snapshot', '2021-04-07T14:04:09Z'), + ('21w10a', 'snapshot', '2021-03-10T15:24:38Z'), + ('21w07a', 'snapshot', '2021-02-17T16:35:40Z'), + ('21w05a', 'snapshot', '2021-02-03T15:56:54Z'), + ('1.16.5-rc1', 'snapshot', '2021-01-13T15:58:55Z'), + ('20w48a', 'snapshot', '2020-11-25T15:42:24Z'), + ('1.16.4', 'release', '2020-10-29T15:49:37Z'), + ('1.16.4-pre1', 'snapshot', '2020-10-13T14:36:07Z'), + ('1.16.2', 'release', '2020-08-11T10:13:46Z'), + ('1.16.2-pre3', 'snapshot', '2020-08-06T16:44:52Z'), + ('20w30a', 'snapshot', '2020-07-22T15:05:15Z'), + ('1.16-rc1', 'snapshot', '2020-06-18T12:49:28Z'), + ('1.16-pre6', 'snapshot', '2020-06-15T16:57:57Z'), + ('1.16-pre3', 'snapshot', '2020-06-10T14:57:43Z'), + ('20w22a', 'snapshot', '2020-05-29T11:25:02Z'), + ('20w20a', 'snapshot', '2020-05-13T15:11:43Z'), + ('20w17a', 'snapshot', '2020-04-22T13:47:50Z'), + ('20w14a', 'snapshot', '2020-04-02T14:28:06Z'), + ('20w13a', 'snapshot', '2020-03-25T17:05:33Z'), + ('20w07a', 'snapshot', '2020-02-14T13:20:49Z'), + ('1.15.1-pre1', 'snapshot', '2019-12-12T14:02:30Z'), + ('1.15-pre6', 'snapshot', '2019-12-06T12:04:30Z'), + ('1.15-pre3', 'snapshot', '2019-11-28T17:17:50Z'), + ('19w46b', 'snapshot', '2019-11-14T13:29:24Z'), + ('19w45a', 'snapshot', '2019-11-07T16:19:20Z'), + ('19w41a', 'snapshot', '2019-10-09T15:21:35Z'), + ('19w36a', 'snapshot', '2019-09-04T11:19:34Z'), + ('1.14.4', 'release', '2019-07-19T09:25:47Z'), + ('1.14.4-pre5', 'snapshot', '2019-07-11T10:52:33Z'), + ('1.14.4-pre2', 'snapshot', '2019-07-04T14:41:05Z'), + ('1.14.3-pre4', 'snapshot', '2019-06-19T11:44:29Z'), + ('1.14.3-pre1', 'snapshot', '2019-06-03T14:34:20Z'), + ('1.14.2-pre3', 'snapshot', '2019-05-22T13:12:51Z'), + ('1.14.1', 'release', '2019-05-13T11:10:12Z'), + ('1.14', 'release', '2019-04-23T14:52:44Z'), + ('1.14-pre3', 'snapshot', '2019-04-16T13:57:10Z'), + ('19w14b', 'snapshot', '2019-04-05T10:33:58Z'), + ('19w13b', 'snapshot', '2019-03-29T16:53:22Z'), + ('19w12a', 'snapshot', '2019-03-20T16:47:34Z'), + ('19w09a', 'snapshot', '2019-02-27T14:44:30Z'), + ('19w07a', 'snapshot', '2019-02-13T16:12:08Z'), + ('19w04b', 'snapshot', '2019-01-25T12:20:15Z'), + ('19w03b', 'snapshot', '2019-01-17T16:43:27Z'), + ('18w50a', 'snapshot', '2018-12-12T14:58:13Z'), + ('18w48a', 'snapshot', '2018-11-29T13:11:38Z'), + ('18w43c', 'snapshot', '2018-10-26T08:40:46Z'), + ('1.13.2', 'release', '2018-10-22T11:41:07Z'), + ('1.13.1', 'release', '2018-08-22T14:03:42Z'), + ('18w33a', 'snapshot', '2018-08-15T14:28:56Z'), + ('18w30b', 'snapshot', '2018-07-26T16:06:57Z'), + ('1.13-pre10', 'snapshot', '2018-07-17T14:48:06Z'), + ('1.13-pre7', 'snapshot', '2018-07-10T14:21:42Z'), + ('1.13-pre1', 'snapshot', '2018-06-04T15:17:34Z'), + ('18w22a', 'snapshot', '2018-05-29T13:23:55Z'), + ('18w20c', 'snapshot', '2018-05-17T14:06:56Z'), + ('20w27a', 'snapshot', '2020-07-01T15:07:35Z'), + ('20w10a', 'snapshot', '2020-03-04T16:21:41Z'), + ('1.14-pre2', 'snapshot', '2019-04-12T11:38:53Z'), + ('1.16.4-rc1', 'snapshot', '2020-10-27T16:31:08Z'), + ('1.15-pre7', 'snapshot', '2019-12-09T12:14:11Z'), + ('1.16.2-pre2', 'snapshot', '2020-08-05T15:30:50Z'), + ('1.16.4-pre2', 'snapshot', '2020-10-22T15:32:17Z'), + ('1.16.3', 'release', '2020-09-10T13:42:37Z'), + ('1.16.2-rc2', 'snapshot', '2020-08-10T11:43:36Z'), + ('1.16.2-pre1', 'snapshot', '2020-07-29T13:19:05Z'), + ('20w29a', 'snapshot', '2020-07-15T14:13:47Z'), + ('20w28a', 'snapshot', '2020-07-08T15:10:40Z'), + ('1.16', 'release', '2020-06-23T16:20:52Z'), + ('1.16-pre7', 'snapshot', '2020-06-16T15:31:35Z'), + ('1.16-pre5', 'snapshot', '2020-06-12T14:33:59Z'), + ('1.16-pre2', 'snapshot', '2020-06-05T10:47:59Z'), + ('1.16-pre1', 'snapshot', '2020-06-04T18:17:51Z'), + ('20w21a', 'snapshot', '2020-05-20T12:07:18Z'), + ('20w20b', 'snapshot', '2020-05-14T08:16:26Z'), + ('20w19a', 'snapshot', '2020-05-06T16:23:24Z'), + ('20w18a', 'snapshot', '2020-04-29T15:16:34Z'), + ('20w15a', 'snapshot', '2020-04-08T12:29:24Z'), + ('20w14infinite', 'snapshot', '2020-04-01T12:47:08Z'), + ('20w12a', 'snapshot', '2020-03-18T16:42:06Z'), + ('20w11a', 'snapshot', '2020-03-11T16:28:27Z'), + ('20w08a', 'snapshot', '2020-02-19T13:30:09Z'), + ('20w06a', 'snapshot', '2020-02-05T16:05:22Z'), + ('1.15.1', 'release', '2019-12-16T10:29:47Z'), + ('1.15', 'release', '2019-12-09T13:13:38Z'), + ('1.15-pre5', 'snapshot', '2019-12-05T13:20:00Z'), + ('1.15-pre4', 'snapshot', '2019-12-03T12:24:24Z'), + ('1.15-pre2', 'snapshot', '2019-11-25T18:09:38Z'), + ('1.15-pre1', 'snapshot', '2019-11-21T17:01:17Z'), + ('19w46a', 'snapshot', '2019-11-13T16:37:46Z'), + ('19w45b', 'snapshot', '2019-11-08T12:42:44Z'), + ('19w39a', 'snapshot', '2019-09-27T10:13:33Z'), + ('19w38a', 'snapshot', '2019-09-18T10:03:22Z'), + ('19w37a', 'snapshot', '2019-09-11T11:46:44Z'), + ('19w35a', 'snapshot', '2019-08-28T15:01:44Z'), + ('19w34a', 'snapshot', '2019-08-22T12:06:21Z'), + ('1.14.4-pre7', 'snapshot', '2019-07-18T11:32:36Z'), + ('1.14.4-pre6', 'snapshot', '2019-07-15T12:39:49Z'), + ('1.14.4-pre4', 'snapshot', '2019-07-10T12:53:29Z'), + ('1.14.4-pre3', 'snapshot', '2019-07-08T11:21:42Z'), + ('1.14.4-pre1', 'snapshot', '2019-07-03T13:01:01Z'), + ('1.14.3', 'release', '2019-06-24T12:52:52Z'), + ('1.14.3-pre2', 'snapshot', '2019-06-07T09:11:29Z'), + ('1.14.2', 'release', '2019-05-27T11:48:25Z'), + ('1.14.2-pre4', 'snapshot', '2019-05-27T07:21:11Z'), + ('1.14.2-pre2', 'snapshot', '2019-05-17T12:21:03Z'), + ('1.14.1-pre2', 'snapshot', '2019-05-09T14:01:04Z'), + ('1.14.1-pre1', 'snapshot', '2019-05-07T14:44:42Z'), + ('1.14-pre5', 'snapshot', '2019-04-18T11:05:19Z'), + ('1.14-pre4', 'snapshot', '2019-04-17T15:31:12Z'), + ('1.14-pre1', 'snapshot', '2019-04-10T14:24:16Z'), + ('19w14a', 'snapshot', '2019-04-03T13:45:00Z'), + ('19w13a', 'snapshot', '2019-03-27T15:15:31Z'), + ('19w12b', 'snapshot', '2019-03-21T15:20:01Z'), + ('19w11b', 'snapshot', '2019-03-14T14:26:23Z'), + ('19w11a', 'snapshot', '2019-03-13T13:59:29Z'), + ('19w08b', 'snapshot', '2019-02-21T13:38:09Z'), + ('19w08a', 'snapshot', '2019-02-20T14:56:58Z'), + ('19w06a', 'snapshot', '2019-02-06T16:24:13Z'), + ('19w05a', 'snapshot', '2019-01-30T15:16:49Z'), + ('19w04a', 'snapshot', '2019-01-24T15:31:52Z'), + ('19w03c', 'snapshot', '2019-01-18T11:27:13Z'), + ('19w03a', 'snapshot', '2019-01-16T16:45:02Z'), + ('19w02a', 'snapshot', '2019-01-09T15:52:07Z'), + ('18w49a', 'snapshot', '2018-12-05T12:24:30Z'), + ('1.16.3-rc1', 'snapshot', '2020-09-07T12:34:06Z'), + ('1.16.2-rc1', 'snapshot', '2020-08-07T14:35:39Z'), + ('1.16-pre8', 'snapshot', '2020-06-17T14:45:23Z'), + ('1.16-pre4', 'snapshot', '2020-06-11T15:45:55Z'), + ('20w16a', 'snapshot', '2020-04-15T14:13:01Z'), + ('20w09a', 'snapshot', '2020-02-26T16:43:08Z'), + ('19w44a', 'snapshot', '2019-10-30T15:31:44Z'), + ('19w42a', 'snapshot', '2019-10-16T15:30:39Z'), + ('19w40a', 'snapshot', '2019-10-02T13:40:26Z'), + ('3D-Shareware-v1.34', 'snapshot', '2019-04-01T11:18:08Z'), + ('1.13-pre5', 'snapshot', '2018-06-28T13:58:53Z'), + ('1.13-pre2', 'snapshot', '2018-06-15T09:20:00Z'), + ('18w22c', 'snapshot', '2018-05-31T13:53:15Z'), + ('16w36a', 'snapshot', '2016-09-08T14:55:10Z'), + ('16w32a', 'snapshot', '2016-08-10T12:30:10Z'), + ('1.10.1', 'release', '2016-06-22T10:13:22Z'), + ('18w22b', 'snapshot', '2018-05-30T13:48:58Z'), + ('18w21a', 'snapshot', '2018-05-23T13:11:49Z'), + ('18w19a', 'snapshot', '2018-05-08T13:05:19Z'), + ('18w16a', 'snapshot', '2018-04-19T14:46:35Z'), + ('18w14b', 'snapshot', '2018-04-05T14:44:02Z'), + ('18w14a', 'snapshot', '2018-04-04T14:36:14Z'), + ('18w10a', 'snapshot', '2018-03-06T15:54:24Z'), + ('18w09a', 'snapshot', '2018-03-01T14:15:10Z'), + ('18w08a', 'snapshot', '2018-02-21T14:59:00Z'), + ('18w07c', 'snapshot', '2018-02-16T13:23:32Z'), + ('18w07a', 'snapshot', '2018-02-14T17:34:13Z'), + ('18w03a', 'snapshot', '2018-01-17T14:25:24Z'), + ('18w01a', 'snapshot', '2018-01-03T13:29:30Z'), + ('17w50a', 'snapshot', '2017-12-11T15:28:08Z'), + ('17w49a', 'snapshot', '2017-12-06T14:24:30Z'), + ('17w48a', 'snapshot', '2017-11-27T15:36:33Z'), + ('17w47a', 'snapshot', '2017-11-22T12:40:05Z'), + ('17w46a', 'snapshot', '2017-11-15T15:21:55Z'), + ('17w45a', 'snapshot', '2017-11-08T15:48:00Z'), + ('1.12.2', 'release', '2017-09-18T08:39:46Z'), + ('1.12.1', 'release', '2017-08-03T12:40:39Z'), + ('1.12.1-pre1', 'snapshot', '2017-08-02T10:53:55Z'), + ('1.12', 'release', '2017-06-02T13:50:27Z'), + ('1.12-pre7', 'snapshot', '2017-05-31T10:56:41Z'), + ('1.12-pre5', 'snapshot', '2017-05-19T07:43:28Z'), + ('1.12-pre4', 'snapshot', '2017-05-18T12:28:16Z'), + ('1.12-pre2', 'snapshot', '2017-05-11T12:11:12Z'), + ('1.12-pre1', 'snapshot', '2017-05-10T11:37:17Z'), + ('17w18a', 'snapshot', '2017-05-03T14:50:23Z'), + ('17w17b', 'snapshot', '2017-04-27T13:24:23Z'), + ('17w16b', 'snapshot', '2017-04-21T12:02:59Z'), + ('17w14a', 'snapshot', '2017-04-05T13:58:01Z'), + ('17w13b', 'snapshot', '2017-03-31T11:06:35Z'), + ('17w06a', 'snapshot', '2017-02-08T13:16:29Z'), + ('1.11.2', 'release', '2016-12-21T09:29:12Z'), + ('16w50a', 'snapshot', '2016-12-15T14:38:52Z'), + ('1.11', 'release', '2016-11-14T14:34:40Z'), + ('16w44a', 'snapshot', '2016-11-03T14:17:11Z'), + ('16w43a', 'snapshot', '2016-10-27T09:00:51Z'), + ('16w40a', 'snapshot', '2016-10-06T13:57:59Z'), + ('16w39b', 'snapshot', '2016-09-29T14:39:39Z'), + ('16w39a', 'snapshot', '2016-09-28T13:32:06Z'), + ('1.10', 'release', '2016-06-08T13:06:18Z'), + ('1.10-pre1', 'snapshot', '2016-06-02T14:45:16Z'), + ('16w21b', 'snapshot', '2016-05-26T12:47:22Z'), + ('16w20a', 'snapshot', '2016-05-18T12:45:14Z'), + ('1.9.4', 'release', '2016-05-10T10:17:16Z'), + ('1.9.3-pre3', 'snapshot', '2016-05-03T09:28:11Z'), + ('1.9.3-pre2', 'snapshot', '2016-04-27T13:33:20Z'), + ('18w47b', 'snapshot', '2018-11-23T10:46:41Z'), + ('18w45a', 'snapshot', '2018-11-07T14:40:06Z'), + ('18w44a', 'snapshot', '2018-10-31T15:29:16Z'), + ('18w43b', 'snapshot', '2018-10-24T15:02:30Z'), + ('18w43a', 'snapshot', '2018-10-24T10:52:16Z'), + ('1.13.2-pre2', 'snapshot', '2018-10-18T14:46:12Z'), + ('1.13.2-pre1', 'snapshot', '2018-10-16T13:40:58Z'), + ('1.13.1-pre2', 'snapshot', '2018-08-20T13:52:09Z'), + ('1.13.1-pre1', 'snapshot', '2018-08-16T13:08:44Z'), + ('18w32a', 'snapshot', '2018-08-08T13:16:57Z'), + ('18w31a', 'snapshot', '2018-08-01T12:54:44Z'), + ('18w30a', 'snapshot', '2018-07-25T14:29:31Z'), + ('1.13', 'release', '2018-07-18T15:11:46Z'), + ('1.13-pre9', 'snapshot', '2018-07-16T14:17:42Z'), + ('1.13-pre8', 'snapshot', '2018-07-13T11:45:00Z'), + ('1.13-pre6', 'snapshot', '2018-07-04T12:36:00Z'), + ('1.13-pre3', 'snapshot', '2018-06-21T12:57:11Z'), + ('18w21b', 'snapshot', '2018-05-25T10:09:09Z'), + ('18w20a', 'snapshot', '2018-05-15T14:02:25Z'), + ('18w03b', 'snapshot', '2018-01-17T15:09:14Z'), + ('1.12.2-pre2', 'snapshot', '2017-09-15T08:21:17Z'), + ('16w35a', 'snapshot', '2016-09-01T13:13:38Z'), + ('16w32b', 'snapshot', '2016-08-11T14:34:29Z'), + ('17w16a', 'snapshot', '2017-04-20T13:58:35Z'), + ('15w31c', 'snapshot', '2015-07-31T13:45:08Z'), + ('14w33b', 'snapshot', '2014-08-15T16:23:51Z'), + ('1.9.1', 'release', '2016-03-30T13:43:07Z'), + ('15w43c', 'snapshot', '2015-10-23T15:35:55Z'), + ('15w42a', 'snapshot', '2015-10-14T13:25:14Z'), + ('14w32c', 'snapshot', '2014-08-08T14:11:20Z'), + ('1.11-pre1', 'snapshot', '2016-11-08T13:42:50Z'), + ('1.9.1-pre1', 'snapshot', '2016-03-09T16:27:29Z'), + ('14w19a', 'snapshot', '2014-05-08T14:24:19Z'), + ('14w17a', 'snapshot', '2014-04-24T15:44:49Z'), + ('1.7.8', 'release', '2014-04-09T07:58:16Z'), + ('14w11a', 'snapshot', '2014-03-13T14:02:50Z'), + ('16w42a', 'snapshot', '2016-10-19T11:17:47Z'), + ('16w39c', 'snapshot', '2016-09-30T14:11:48Z'), + ('16w38a', 'snapshot', '2016-09-20T12:40:49Z'), + ('1.9-pre3', 'snapshot', '2016-02-24T15:52:36Z'), + ('16w07b', 'snapshot', '2016-02-16T15:22:39Z'), + ('15w40b', 'snapshot', '2015-09-30T14:13:54Z'), + ('15w39b', 'snapshot', '2015-09-21T15:09:52Z'), + ('1.8.8', 'release', '2015-07-27T10:31:28Z'), + ('1.8.5', 'release', '2015-05-22T11:15:28Z'), + ('1.8.2-pre6', 'snapshot', '2015-01-30T11:58:24Z'), + ('14w31a', 'snapshot', '2014-07-30T15:38:05Z'), + ('14w30a', 'snapshot', '2014-07-23T13:15:42Z'), + ('1.8.2-pre3', 'snapshot', '2015-01-15T16:44:33Z'), + ('16w33a', 'snapshot', '2016-08-17T12:48:57Z'), + ('14w28b', 'snapshot', '2014-07-10T14:28:48Z'), + ('14w10c', 'snapshot', '2014-03-07T13:49:55Z'), + ('14w06b', 'snapshot', '2014-02-06T17:30:42Z'), + ('1.10.2', 'release', '2016-06-23T09:17:32Z'), + ('16w05b', 'snapshot', '2016-02-04T15:28:02Z'), + ('15w38a', 'snapshot', '2015-09-16T14:22:31Z'), + ('15w36c', 'snapshot', '2015-09-02T16:07:22Z'), + ('1.10-pre2', 'snapshot', '2016-06-07T14:56:34Z'), + ('16w21a', 'snapshot', '2016-05-25T13:12:09Z'), + ('16w03a', 'snapshot', '2016-01-20T14:29:24Z'), + ('1.8.1', 'release', '2014-11-24T14:13:31Z'), + ('1.8.1-pre3', 'snapshot', '2014-10-23T12:59:42Z'), + ('14w27a', 'snapshot', '2014-07-02T16:07:20Z'), + ('1.12-pre3', 'snapshot', '2017-05-17T14:09:18Z'), + ('15w35e', 'snapshot', '2015-08-28T18:14:02Z'), + ('15w35b', 'snapshot', '2015-08-24T15:39:18Z'), + ('14w26a', 'snapshot', '2014-06-25T13:59:27Z'), + ('14w21b', 'snapshot', '2014-05-22T15:17:55Z'), + ('14w20a', 'snapshot', '2014-05-15T14:01:20Z'), + ('1.7.10-pre3', 'snapshot', '2014-05-14T15:29:23Z'), + ('14w05a', 'snapshot', '2014-01-30T15:32:41Z'), + ('1.9.3', 'release', '2016-05-10T08:33:35Z'), + ('15w34c', 'snapshot', '2015-08-21T12:45:20Z'), + ('15w33c', 'snapshot', '2015-08-14T13:10:46Z'), + ('1.8', 'release', '2014-09-02T08:24:35Z'), + ('17w18b', 'snapshot', '2017-05-04T13:40:22Z'), + ('17w17a', 'snapshot', '2017-04-26T13:48:23Z'), + ('17w15a', 'snapshot', '2017-04-12T09:30:50Z'), + ('16w14a', 'snapshot', '2016-04-07T12:47:51Z'), + ('15w51a', 'snapshot', '2015-12-17T14:02:37Z'), + ('1.8.9', 'release', '2015-12-03T09:24:39Z'), + ('15w47b', 'snapshot', '2015-11-19T14:48:03Z'), + ('15w45a', 'snapshot', '2015-11-05T13:04:07Z'), + ('15w32c', 'snapshot', '2015-08-07T14:08:17Z'), + ('1.8-pre1', 'snapshot', '2014-08-21T13:56:26Z'), + ('14w34b', 'snapshot', '2014-08-18T15:14:28Z'), + ('14w03b', 'snapshot', '2014-01-16T16:36:19Z'), + ('14w02b', 'snapshot', '2014-01-09T15:45:55Z'), + ('17w13a', 'snapshot', '2017-03-30T09:32:19Z'), + ('1.11.1', 'release', '2016-12-20T14:05:34Z'), + ('14w25b', 'snapshot', '2014-06-19T12:29:48Z'), + ('1.9.2', 'release', '2016-03-30T15:23:55Z'), + ('1.7.6-pre2', 'snapshot', '2014-03-08T11:00:01Z'), + ('15w50a', 'snapshot', '2015-12-09T15:35:57Z'), + ('14w33c', 'snapshot', '2014-08-15T18:00:26Z'), + ('1.7.6-pre1', 'snapshot', '2014-03-08T11:00:00Z'), + ('1.9.1-pre3', 'snapshot', '2016-03-11T09:20:36Z'), + ('14w10b', 'snapshot', '2014-03-06T16:25:39Z'), + ('1.9.1-pre2', 'snapshot', '2016-03-10T15:06:03Z'), + ('14w33a', 'snapshot', '2014-08-13T15:08:14Z'), + ('14w32d', 'snapshot', '2014-08-08T15:13:41Z'), + ('14w32b', 'snapshot', '2014-08-07T14:45:17Z'), + ('14w10a', 'snapshot', '2014-03-06T14:23:04Z'), + ('1.7.5', 'release', '2014-02-26T09:22:17Z'), + ('14w05b', 'snapshot', '2014-01-31T14:05:50Z'), + ('1.9', 'release', '2016-02-29T13:49:54Z'), + ('1.9-pre4', 'snapshot', '2016-02-26T15:21:11Z'), + ('1.9-pre2', 'snapshot', '2016-02-18T17:41:00Z'), + ('1.9-pre1', 'snapshot', '2016-02-17T15:23:19Z'), + ('16w07a', 'snapshot', '2016-02-15T15:48:46Z'), + ('16w06a', 'snapshot', '2016-02-10T15:06:41Z'), + ('14w32a', 'snapshot', '2014-08-06T14:01:16Z'), + ('14w30c', 'snapshot', '2014-07-24T14:39:09Z'), + ('14w04b', 'snapshot', '2014-01-24T15:48:46Z'), + ('14w04a', 'snapshot', '2014-01-23T15:26:13Z'), + ('14w03a', 'snapshot', '2014-01-16T14:45:13Z'), + ('16w05a', 'snapshot', '2016-02-03T15:48:38Z'), + ('16w04a', 'snapshot', '2016-01-28T15:37:24Z'), + ('14w30b', 'snapshot', '2014-07-23T15:03:03Z'), + ('14w29b', 'snapshot', '2014-07-16T17:27:40Z'), + ('14w29a', 'snapshot', '2014-07-16T15:18:17Z'), + ('14w28a', 'snapshot', '2014-07-09T15:42:36Z'), + ('14w27b', 'snapshot', '2014-07-02T18:34:56Z'), + ('14w02c', 'snapshot', '2014-01-10T15:42:36Z'), + ('16w02a', 'snapshot', '2016-01-13T15:15:16Z'), + ('1.8.2-pre4', 'snapshot', '2015-01-16T14:19:59Z'), + ('14w26c', 'snapshot', '2014-06-26T15:05:03Z'), + ('14w25a', 'snapshot', '2014-06-18T15:52:28Z'), + ('14w21a', 'snapshot', '2014-05-22T14:44:33Z'), + ('14w20b', 'snapshot', '2014-05-15T16:47:21Z'), + ('14w02a', 'snapshot', '2014-01-09T14:44:41Z'), + ('1.8.2-pre2', 'snapshot', '2015-01-15T15:07:31Z'), + ('1.8.2-pre1', 'snapshot', '2014-12-18T11:29:41Z'), + ('1.8.1-pre5', 'snapshot', '2014-11-19T14:30:48Z'), + ('1.8.1-pre4', 'snapshot', '2014-11-06T14:10:50Z'), + ('1.7.10', 'release', '2014-05-14T17:29:23Z'), + ('1.7.10-pre4', 'snapshot', '2014-05-14T16:29:23Z'), + ('1.8.1-pre2', 'snapshot', '2014-10-16T14:19:27Z'), + ('1.7.10-pre2', 'snapshot', '2014-05-14T14:29:23Z'), + ('1.8.1-pre1', 'snapshot', '2014-10-15T13:25:11Z'), + ('1.8-pre3', 'snapshot', '2014-08-28T09:40:54Z'), + ('14w34d', 'snapshot', '2014-08-20T12:46:59Z'), + ('1.7.10-pre1', 'snapshot', '2014-05-14T13:29:23Z'), + ('14w18b', 'snapshot', '2014-05-02T11:38:17Z'), + ('14w18a', 'snapshot', '2014-04-30T10:25:35Z'), + ('14w34c', 'snapshot', '2014-08-19T15:31:24Z'), + ('1.7.9', 'release', '2014-04-14T13:29:23Z'), + ('14w34a', 'snapshot', '2014-08-18T14:14:11Z'), + ('14w26b', 'snapshot', '2014-06-25T15:08:39Z'), + ('1.7.7', 'release', '2014-04-09T07:52:16Z'), + ('16w15a', 'snapshot', '2016-04-11T14:38:28Z'), + ('15w51b', 'snapshot', '2015-12-17T15:30:41Z'), + ('1.7.6', 'release', '2014-04-09T07:52:06Z'), + ('1.6.2', 'release', '2013-07-05T13:09:02Z'), + ('1.6.1', 'release', '2013-06-28T14:48:41Z'), + ('13w26a', 'snapshot', '2013-06-24T16:06:06Z'), + ('13w25c', 'snapshot', '2013-06-20T15:23:37Z'), + ('13w25a', 'snapshot', '2013-06-17T14:08:06Z'), + ('13w24b', 'snapshot', '2013-06-14T12:19:13Z'), + ('13w23b', 'snapshot', '2013-06-08T00:32:01Z'), + ('13w23a', 'snapshot', '2013-06-07T16:04:20Z'), + ('13w21b', 'snapshot', '2013-05-27T08:50:42Z'), + ('13w21a', 'snapshot', '2013-05-23T15:38:28Z'), + ('13w18c', 'snapshot', '2013-05-03T09:19:35Z'), + ('13w18b', 'snapshot', '2013-05-02T17:12:25Z'), + ('13w17a', 'snapshot', '2013-04-25T15:50:00Z'), + ('1.5.2', 'release', '2013-04-25T15:45:00Z'), + ('13w16a', 'snapshot', '2013-04-21T12:49:30Z'), + ('1.5.1', 'release', '2013-03-20T10:00:00Z'), + ('1.4.7', 'release', '2012-12-27T22:00:00Z'), + ('1.4.5', 'release', '2012-12-19T22:00:00Z'), + ('1.4.4', 'release', '2012-12-13T22:00:00Z'), + ('1.4.3', 'snapshot', '2012-11-30T22:00:00Z'), + ('1.4.1', 'snapshot', '2012-11-22T22:00:00Z'), + ('b1.4', 'old_beta', '2011-03-30T22:00:00Z'), + ('b1.3_01', 'old_beta', '2011-02-22T22:00:00Z'), + ('b1.2_02', 'old_beta', '2011-01-20T22:00:00Z'), + ('b1.2_01', 'old_beta', '2011-01-13T22:00:00Z'), + ('b1.1_02', 'old_beta', '2010-12-21T22:00:01Z'), + ('b1.1_01', 'old_beta', '2010-12-21T22:00:00Z'), + ('b1.0_01', 'old_beta', '2010-12-19T22:00:01Z'), + ('b1.0', 'old_beta', '2010-12-19T22:00:00Z'), + ('a1.2.5', 'old_alpha', '2010-11-30T22:00:00Z'), + ('1.21.8', 'release', '2025-07-17T12:04:02Z'), + ('1.20-pre6', 'snapshot', '2023-05-25T12:22:00Z'), + ('1.19.4-rc3', 'snapshot', '2023-03-13T10:03:11Z'), + ('1.18-pre8', 'snapshot', '2021-11-24T14:57:32Z'), + ('1.18-pre6', 'snapshot', '2021-11-22T17:09:05Z'), + ('20w46a', 'snapshot', '2020-11-11T15:30:32Z'), + ('1.16.1', 'release', '2020-06-24T10:31:40Z'), + ('1.15.2-pre2', 'snapshot', '2020-01-16T12:35:57Z'), + ('18w47a', 'snapshot', '2018-11-21T15:45:22Z'), + ('17w43a', 'snapshot', '2017-10-25T14:43:50Z'), + ('17w31a', 'snapshot', '2017-08-01T09:41:23Z'), + ('16w41a', 'snapshot', '2016-10-13T14:28:35Z'), + ('16w15b', 'snapshot', '2016-04-13T13:56:41Z'), + ('1.8-pre2', 'snapshot', '2014-08-25T14:52:18Z'), + ('14w08a', 'snapshot', '2014-02-26T17:00:00Z'), + ('13w36b', 'snapshot', '2013-09-06T12:31:58Z'), + ('rd-132328', 'old_alpha', '2009-05-13T21:28:00Z'), + ('1.8.6', 'release', '2015-05-25T10:31:19Z'), + ('1.8.3', 'release', '2015-02-20T14:00:09Z'), + ('14w11b', 'snapshot', '2014-04-14T14:36:19Z'), + ('13w47a', 'snapshot', '2013-11-21T15:59:58Z'), + ('13w16b', 'snapshot', '2013-04-23T21:51:22Z'), + ('1.5', 'snapshot', '2013-03-06T22:00:00Z'), + ('1.4.6', 'release', '2012-12-19T22:00:01Z'), + ('1.4.2', 'release', '2012-11-24T22:00:00Z'), + ('1.3.2', 'release', '2012-08-15T22:00:00Z'), + ('1.2.5', 'release', '2012-03-29T22:00:00Z'), + ('1.2.2', 'release', '2012-02-29T22:00:01Z'), + ('1.0', 'release', '2011-11-17T22:00:00Z'), + ('b1.7.3', 'old_beta', '2011-07-07T22:00:00Z'), + ('b1.6.6', 'old_beta', '2011-05-30T22:00:00Z'), + ('b1.6.3', 'old_beta', '2011-05-25T22:00:03Z'), + ('1.21.1-rc1', 'snapshot', '2024-08-07T14:29:18Z'), + ('1.19.4-pre4', 'snapshot', '2023-03-08T13:08:22Z'), + ('1.19-pre5', 'snapshot', '2022-06-01T10:56:23Z'), + ('1.18.2-pre1', 'snapshot', '2022-02-18T16:00:32Z'), + ('1.17-pre3', 'snapshot', '2021-06-01T15:43:46Z'), + ('21w06a', 'snapshot', '2021-02-10T17:13:54Z'), + ('1.15.2-pre1', 'snapshot', '2020-01-14T16:19:31Z'), + ('1.14.3-pre3', 'snapshot', '2019-06-14T08:03:33Z'), + ('18w48b', 'snapshot', '2018-11-30T10:37:31Z'), + ('18w46a', 'snapshot', '2018-11-15T13:43:14Z'), + ('18w20b', 'snapshot', '2018-05-16T14:35:35Z'), + ('17w43b', 'snapshot', '2017-10-26T13:36:22Z'), + ('1.9.3-pre1', 'snapshot', '2016-04-21T12:41:42Z'), + ('1.RV-Pre1', 'snapshot', '2016-03-31T16:18:53Z'), + ('b1.6', 'old_beta', '2011-05-25T22:00:00Z'), + ('b1.4_01', 'old_beta', '2011-04-04T22:00:00Z'), + ('b1.3b', 'old_beta', '2011-02-21T22:00:00Z'), + ('b1.2', 'old_beta', '2011-01-12T22:00:00Z'), + ('b1.0.2', 'old_beta', '2010-12-20T22:00:00Z'), + ('a1.2.6', 'old_alpha', '2010-12-02T22:00:00Z'), + ('a1.2.3_04', 'old_alpha', '2010-11-25T22:00:00Z'), + ('a1.2.3', 'old_alpha', '2010-11-23T22:00:00Z'), + ('a1.2.1_01', 'old_alpha', '2010-11-04T22:00:01Z'), + ('a1.2.0_01', 'old_alpha', '2010-10-30T22:00:00Z'), + ('a1.1.2', 'old_alpha', '2010-09-19T22:00:00Z'), + ('a1.0.17_02', 'old_alpha', '2010-08-19T22:00:00Z'), + ('a1.0.14', 'old_alpha', '2010-07-29T22:00:00Z'), + ('a1.0.4', 'old_alpha', '2010-07-08T22:00:00Z'), + ('c0.0.13a', 'old_alpha', '2009-05-30T22:00:00Z'), + ('rd-161348', 'old_alpha', '2009-05-16T11:48:00Z'), + ('a1.2.0_02', 'old_alpha', '2010-11-03T22:00:00Z'), + ('a1.2.0', 'old_alpha', '2010-10-29T22:00:00Z'), + ('a1.1.2_01', 'old_alpha', '2010-09-22T22:00:00Z'), + ('a1.1.0', 'old_alpha', '2010-09-12T22:00:00Z'), + ('a1.0.17_04', 'old_alpha', '2010-08-22T22:00:00Z'), + ('a1.0.16', 'old_alpha', '2010-08-11T22:00:00Z'), + ('a1.0.15', 'old_alpha', '2010-08-03T22:00:00Z'), + ('a1.0.11', 'old_alpha', '2010-07-22T22:00:00Z'), + ('a1.0.5_01', 'old_alpha', '2010-07-12T22:00:00Z'), + ('inf-20100618', 'old_alpha', '2010-06-15T22:00:00Z'), + ('c0.30_01c', 'old_alpha', '2009-12-21T22:00:00Z'), + ('c0.0.13a_03', 'old_alpha', '2009-05-21T22:00:00Z'), + ('c0.0.11a', 'old_alpha', '2009-05-16T22:00:00Z'), + ('rd-160052', 'old_alpha', '2009-05-15T22:52:00Z'), + ('rd-20090515', 'old_alpha', '2009-05-14T22:00:00Z'), + ('rd-132211', 'old_alpha', '2009-05-13T20:11:00Z'), + ('1.21.5-pre1', 'snapshot', '2025-03-11T12:49:44Z'), + ('25w10a', 'snapshot', '2025-03-05T13:11:13Z') +) AS legacy_versions(name, version_type, released_at) +ON CONFLICT (name) DO NOTHING; + +COMMIT; diff --git a/apps/labrinth/src/database/mod.rs b/apps/labrinth/src/database/mod.rs index 7800bb388d..c7db01b16d 100644 --- a/apps/labrinth/src/database/mod.rs +++ b/apps/labrinth/src/database/mod.rs @@ -1,3 +1,20 @@ +use eyre::{Result, WrapErr}; + +pub struct MinecraftMetaDb(pub toasty::Db); + +impl MinecraftMetaDb { + pub async fn connect() -> Result { + let database_url = &crate::env::ENV.MINECRAFT_META_DATABASE_URL; + + let db = toasty::Db::builder() + .models(toasty::models!(minecraft_meta_model::*)) + .connect(database_url.as_str()) + .await + .wrap_err("connecting to Minecraft metadata database")?; + Ok(Self(db)) + } +} + pub mod advisory_lock; pub mod models; mod postgres_database; diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 3edd0f8a87..67027ad6b0 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -3,6 +3,7 @@ use std::{any::type_name, convert::Infallible, str::FromStr, sync::LazyLock}; use derive_more::{Deref, DerefMut}; use eyre::{Context, eyre}; use serde::de::DeserializeOwned; +use url::Url; use xredis::{ CacheLockingStrategy, ReadReplicaStrategy, RedisConnectionType, @@ -134,6 +135,7 @@ vars! { LABRINTH_SUBSCRIPTIONS_KEY: String = ""; RATE_LIMIT_IGNORE_KEY: String = ""; DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth"; + MINECRAFT_META_DATABASE_URL: Url; // Redis REDIS_TOPOLOGY: RedisTopology = RedisTopology::Standalone; diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 73c1ec057b..327cd7ad3a 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -57,6 +57,7 @@ pub struct Pepper { pub struct LabrinthConfig { pub pool: PgPool, pub ro_pool: ReadOnlyPgPool, + pub minecraft_meta_db: web::Data, pub redis_pool: RedisPool, pub clickhouse: Client, pub file_host: web::Data, @@ -82,6 +83,7 @@ pub struct LabrinthConfig { pub fn app_setup( pool: PgPool, ro_pool: ReadOnlyPgPool, + minecraft_meta_db: web::Data, redis_pool: RedisPool, search_backend: actix_web::web::Data, clickhouse: &mut Client, @@ -249,6 +251,7 @@ pub fn app_setup( LabrinthConfig { pool, ro_pool, + minecraft_meta_db, redis_pool, clickhouse: clickhouse.clone(), file_host, @@ -301,6 +304,7 @@ pub fn app_data_config( .app_data(web::Data::new(labrinth_config.redis_pool.clone())) .app_data(web::Data::new(labrinth_config.pool.clone())) .app_data(web::Data::new(labrinth_config.ro_pool.clone())) + .app_data(labrinth_config.minecraft_meta_db.clone()) .app_data(labrinth_config.file_host.clone()) .app_data(web::Data::from( labrinth_config.search_state.backend.clone(), diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 4342992f2a..3cd396d9aa 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -125,6 +125,11 @@ async fn app() -> std::io::Result<()> { let (pool, ro_pool) = database::connect_all() .await .expect("Database connection failed"); + let minecraft_meta_db = actix_web::web::Data::new( + database::MinecraftMetaDb::connect() + .await + .expect("Minecraft metadata database connection failed"), + ); // Redis connector let redis_pool = redis::from_env("").await; @@ -227,6 +232,7 @@ async fn app() -> std::io::Result<()> { let labrinth_config = labrinth::app_setup( pool.clone(), ro_pool.clone(), + minecraft_meta_db, redis_pool.clone(), search_backend.clone(), &mut clickhouse, diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs index 97fb991b87..f62ca91211 100644 --- a/apps/labrinth/src/test/mod.rs +++ b/apps/labrinth/src/test/mod.rs @@ -55,9 +55,16 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { .expect("Kafka connection failed"), ); + let minecraft_meta_db = web::Data::new( + crate::database::MinecraftMetaDb::connect() + .await + .expect("Minecraft metadata database connection failed"), + ); + crate::app_setup( pool.clone(), ro_pool.clone(), + minecraft_meta_db, redis_pool.clone(), search_backend.into(), &mut clickhouse, diff --git a/scripts/seed-db.sh b/scripts/seed-db.sh index 13b5305dc6..289ae77624 100755 --- a/scripts/seed-db.sh +++ b/scripts/seed-db.sh @@ -1,4 +1,21 @@ #!/usr/bin/env bash -source .env -psql $DATABASE_URL < fixtures/labrinth-seed-data-202508052143.sql +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +LABRINTH_DIR="$REPO_ROOT/apps/labrinth" + +if [[ -f "$LABRINTH_DIR/.env" ]]; then + source "$LABRINTH_DIR/.env" +fi +if [[ -f "$LABRINTH_DIR/.env.local" ]]; then + source "$LABRINTH_DIR/.env.local" +fi + +: "${DATABASE_URL:?DATABASE_URL must be configured}" +: "${MINECRAFT_META_DATABASE_URL:?MINECRAFT_META_DATABASE_URL must be configured}" + +psql -X --set=ON_ERROR_STOP=1 "$MINECRAFT_META_DATABASE_URL" \ + --file="$LABRINTH_DIR/fixtures/minecraft-meta-seed-data-202508052143.sql" +psql -X --set=ON_ERROR_STOP=1 "$DATABASE_URL" \ + --file="$LABRINTH_DIR/fixtures/labrinth-seed-data-202508052143.sql" From 1d5628719947c849e9e9b11cf0e88b2e8f4dfae5 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:37:46 +0100 Subject: [PATCH 03/13] wip: new schema and component type for version meta --- .../20261013133423_delete_loader_fields.sql | 116 ++++++++++++++++++ apps/labrinth/src/models/exp/component.rs | 14 ++- apps/labrinth/src/models/exp/minecraft.rs | 116 +++++++++++++++++- apps/labrinth/src/models/exp/version.rs | 22 +++- 4 files changed, 259 insertions(+), 9 deletions(-) diff --git a/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql index c4bb1742dd..9c49155d83 100644 --- a/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql +++ b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql @@ -1,3 +1,119 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM version_fields vf + JOIN loader_fields lf ON lf.id = vf.field_id + WHERE lf.field NOT IN ('game_versions', 'environment', 'mrpack_loaders') + ) THEN + RAISE EXCEPTION 'cannot migrate unsupported loader fields'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM version_fields vf + JOIN loader_fields lf ON lf.id = vf.field_id + LEFT JOIN loader_field_enum_values ev ON ev.id = vf.enum_value + WHERE vf.int_value IS NOT NULL + OR vf.string_value IS NOT NULL + OR ev.id IS NULL + OR ev.enum_id IS DISTINCT FROM lf.enum_type + OR (lf.field = 'game_versions' AND lf.field_type <> 'array_enum') + OR (lf.field = 'environment' AND ( + lf.field_type <> 'enum' + OR ev.value NOT IN ( + 'client_and_server', 'client_only', 'client_only_server_optional', + 'singleplayer_only', 'server_only', 'server_only_client_optional', + 'dedicated_server_only', 'client_or_server', + 'client_or_server_prefers_both', 'unknown' + ) + )) + OR (lf.field = 'mrpack_loaders' AND ( + lf.field_type <> 'array_enum' + OR ev.value NOT IN ( + 'forge', 'fabric', 'quilt', 'modloader', 'rift', 'liteloader', + 'minecraft', 'bukkit', 'spigot', 'paper', 'purpur', 'bungeecord', + 'waterfall', 'velocity', 'sponge', 'optifine', 'iris', 'canvas', + 'vanilla', 'datapack', 'folia', 'neoforge', 'babric', + 'bta-babric', 'java-agent', 'legacy-fabric', 'nilloader', 'ornithe' + ) + )) + ) THEN + RAISE EXCEPTION 'cannot migrate invalid Minecraft loader field values'; + END IF; + + IF EXISTS ( + SELECT vf.version_id + FROM version_fields vf + JOIN loader_fields lf ON lf.id = vf.field_id + WHERE lf.field = 'environment' + GROUP BY vf.version_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'cannot migrate multiple environments for a version'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM versions v + WHERE v.components->'minecraft_java_server' IS NOT NULL + AND v.components->'minecraft_java_server' <> 'null'::jsonb + AND EXISTS (SELECT 1 FROM version_fields vf WHERE vf.version_id = v.id) + ) THEN + RAISE EXCEPTION 'cannot migrate loader fields on a Minecraft server version'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM versions v + WHERE v.components->'minecraft_mod' IS NOT NULL + AND v.components->'minecraft_mod' <> 'null'::jsonb + AND EXISTS (SELECT 1 FROM version_fields vf WHERE vf.version_id = v.id) + ) THEN + RAISE EXCEPTION 'cannot overwrite an existing Minecraft mod version component'; + END IF; +END; +$$; + +WITH metadata AS ( + SELECT + vf.version_id, + COALESCE( + jsonb_agg(DISTINCT ev.value ORDER BY ev.value) + FILTER (WHERE lf.field = 'game_versions'), + '[]'::jsonb + ) AS game_versions, + MAX(ev.value) FILTER (WHERE lf.field = 'environment') AS environment, + COALESCE( + jsonb_agg(DISTINCT ev.value ORDER BY ev.value) + FILTER (WHERE lf.field = 'mrpack_loaders'), + '[]'::jsonb + ) AS mrpack_loaders + FROM version_fields vf + JOIN loader_fields lf ON lf.id = vf.field_id + JOIN loader_field_enum_values ev ON ev.id = vf.enum_value + GROUP BY vf.version_id +) +UPDATE versions v +SET components = jsonb_set( + v.components, + '{minecraft_mod}', + jsonb_build_object( + 'game_versions', COALESCE(metadata.game_versions, '[]'::jsonb), + 'environment', metadata.environment, + 'mrpack_loaders', COALESCE(metadata.mrpack_loaders, '[]'::jsonb) + ) +) +FROM versions target +LEFT JOIN metadata ON metadata.version_id = target.id +WHERE v.id = target.id + AND (v.components->'minecraft_mod' IS NULL OR v.components->'minecraft_mod' = 'null'::jsonb) + AND ( + metadata.version_id IS NOT NULL + OR EXISTS (SELECT 1 FROM loaders_versions lv WHERE lv.version_id = v.id) + ) + AND (v.components->'minecraft_java_server' IS NULL OR v.components->'minecraft_java_server' = 'null'::jsonb); + DROP TABLE version_fields; DROP TABLE loader_fields_loaders; DROP TABLE loader_fields; diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index 8e4968c40e..2be0c8026f 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -1,8 +1,14 @@ macro_rules! define { () => {}; + (@entity) => { $crate::models::ids::ProjectId }; + (@entity version) => { $crate::models::ids::VersionId }; + (@context) => { $crate::models::exp::project::ProjectQueryContext }; + (@context version) => { () }; + (@requirements) => { $crate::models::exp::project::ProjectQueryRequirements }; + (@requirements version) => { () }; ( $(#[$meta:meta])* - $vis:vis struct $name:ident { + $vis:vis struct $name:ident $( : $entity:ident )? { $( #[base( $($field_base_meta:meta),* @@ -37,15 +43,15 @@ macro_rules! define { } impl $crate::models::exp::component::Component for $name { - type EntityId = $crate::models::ids::ProjectId; + type EntityId = $crate::models::exp::component::define!(@entity $($entity)?); type Query = $name; type Edit = [< $name Edit >]; } impl $crate::models::exp::component::ComponentQuery for $name { type Component = $name; - type Context = $crate::models::exp::project::ProjectQueryContext; - type Requirements = $crate::models::exp::project::ProjectQueryRequirements; + type Context = $crate::models::exp::component::define!(@context $($entity)?); + type Requirements = $crate::models::exp::component::define!(@requirements $($entity)?); fn collect_requirements( _serial: &Self::Component, diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 54f7e037c5..7a0929e943 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -2,6 +2,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use eyre::Result; +use minecraft_meta_model::model::MinecraftVersionName; use serde::{Deserialize, Serialize}; use serde_binhum::serde_binhum; use tracing::warn; @@ -11,6 +12,7 @@ use crate::{ models::{ exp::{ ProjectComponentKind, + version::{VersionComponent, VersionComponentKind}, component::{self, Component, ComponentEdit, ComponentQuery}, project::{ ProjectComponent, ProjectQueryContext, ProjectQueryRequirements, @@ -109,9 +111,107 @@ pub enum Language { Zu, } +/// Supported Minecraft version environments, including the explicit legacy unknown value. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum Environment { + ClientAndServer, + ClientOnly, + ClientOnlyServerOptional, + SingleplayerOnly, + ServerOnly, + ServerOnlyClientOptional, + DedicatedServerOnly, + ClientOrServer, + ClientOrServerPrefersBoth, + Unknown, +} + +/// Loader field values for mrpack versions, not mrpack manifest dependency keys. +/// The legacy field was populated from every non-mrpack loader. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + utoipa::ToSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum MrpackLoader { + Forge, + Fabric, + Quilt, + Modloader, + Rift, + Liteloader, + Minecraft, + Bukkit, + Spigot, + Paper, + Purpur, + Bungeecord, + Waterfall, + Velocity, + Sponge, + Optifine, + Iris, + Canvas, + Vanilla, + Datapack, + Folia, + Neoforge, + Babric, + #[serde(rename = "bta-babric")] + BtaBabric, + #[serde(rename = "java-agent")] + JavaAgent, + #[serde(rename = "legacy-fabric")] + LegacyFabric, + Nilloader, + Ornithe, +} + component::define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ModProject {} + /// Umbrella for legacy Minecraft mods, modpacks, plugins, data packs, + /// resource packs, and shaders; not restricted to the legacy mod type. + pub struct ModProject {} + + /// Shared version metadata for Minecraft mods, modpacks, plugins, data packs, + /// resource packs, and shaders. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ModVersion: version { + #[base(schema(value_type = Vec))] + #[edit(serde(default), schema(value_type = Option>))] + #[create(required)] + pub game_versions: Vec, + #[base(serde(default))] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + #[create(optional)] + pub environment: Option, + #[base(serde(default))] + #[edit(serde(default))] + #[create(default)] + pub mrpack_loaders: Vec, + } /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -162,7 +262,7 @@ component::define! { /// Version of a Minecraft Java server listing. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct JavaServerVersion {} + pub struct JavaServerVersion: version {} /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -176,6 +276,18 @@ component::define! { } } +impl VersionComponent for ModVersion { + fn kind() -> VersionComponentKind { + VersionComponentKind::MinecraftMod + } +} + +impl VersionComponent for JavaServerVersion { + fn kind() -> VersionComponentKind { + VersionComponentKind::MinecraftJavaServer + } +} + impl ProjectComponent for ModProject { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftMod diff --git a/apps/labrinth/src/models/exp/version.rs b/apps/labrinth/src/models/exp/version.rs index 2df4b90954..a8fe2c65b0 100644 --- a/apps/labrinth/src/models/exp/version.rs +++ b/apps/labrinth/src/models/exp/version.rs @@ -1,6 +1,6 @@ use crate::models::exp::{ base, - component::{Component, ComponentKind}, + component::{self, Component, ComponentKind}, minecraft, }; @@ -8,15 +8,19 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use validator::Validate; +pub trait VersionComponent: Component { + fn kind() -> VersionComponentKind; +} + macro_rules! define_version_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { // kinds - #[expect(dead_code, reason = "static check so $ty implements `Component`")] + #[expect(dead_code, reason = "static check so type implements `VersionComponent`")] const _: () = { - fn assert_implements_component() {} + fn assert_implements_component() {} fn assert_components_implement_trait() { $(assert_implements_component::<$ty>();)* @@ -94,5 +98,17 @@ type Edit = ::Edit; type Query = ::Query; define_version_components![ + (minecraft_mod, MinecraftMod): minecraft::ModVersion, (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion, ]; + +component::relations! { + pub static VERSION_COMPONENT_RELATIONS: VersionComponentKind = { + use VersionComponentKind::*; + + [ + [MinecraftMod].only(), + [MinecraftJavaServer].only(), + ] + } +} From 3dd7a96f6bb58153e59eee71a8e9e4cda8869dc1 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:24:12 +0100 Subject: [PATCH 04/13] wip: adapt queries and types for new ModVersion component --- ...7fa4fb87a205854905fb16cf1b8e715f1e01d.json | 46 - ...8c772541ad0049f706602d01460238f88ffd8.json | 46 - ...dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json | 46 - ...61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json | 58 -- ...9be2a335ea9a32a1d19a450aef02f1c58334e.json | 40 - ...a103e271ec688ffb52f7317823ecdd8eafa0f.json | 40 + ...97ca77a041056b1828f897dd3c063b6cc33db.json | 29 - ...9857ce857ff462c830dd0cb74574f415d4105.json | 15 - ...7d340e5970b27edc76f21b903f362329a6542.json | 46 - ...0a4c9e6d84933464c0e610694743da4783354.json | 40 + ...4558383398ce3229acff187324d8752f4eb72.json | 29 - ...f3d94bd7e6b2a47629291d86a2e7268b75a92.json | 29 + ...f5698f2d50aeea16526a8440a739c7886be7f.json | 42 + ...9358c2b9eac5323ae0795581358ed29e5ba65.json | 58 -- ...836a02ff5dc408c9fc233e8b5ad3b48125dc4.json | 56 - ...9173d608e064a3a1b69a135273de380efe44c.json | 58 -- ...929b7178f81056788ffb207a6c5e4bbcc7a7d.json | 18 - ...4dd66540f9d51b167339b41a2f3858843f287.json | 40 + ...e988a4ee9773eb26947c54a5859a2b80bcd61.json | 29 + ...bfb775fb088579fe23bcb87f50f5a8578f3c0.json | 14 - ...119f81d17197673d831d210e351c149e41fc8.json | 56 - ...4ff96df8525a6ed872be7a64b8f349b36b276.json | 25 - ...512f3984031919a71af59fc10995fb21f480c.json | 64 -- ...c2028b5bcf23aaa68a351ea858d0d56681b4d.json | 15 + ...e0d771f929c7a6ed96245ba5b37dc9d49844c.json | 56 - ...049676aa23656464363293664c5066f164a07.json | 52 - ...636e53a8d55578ce61c364626bd5bc3341519.json | 15 + ...901ba8dca63912e9d7054115052ad89ab4718.json | 58 -- apps/labrinth/src/background_task.rs | 67 +- .../database/models/legacy_loader_fields.rs | 161 +-- .../src/database/models/loader_fields.rs | 955 +++++++++--------- .../src/database/models/project_item.rs | 140 +-- .../src/database/models/version_item.rs | 140 +-- apps/labrinth/src/main.rs | 2 + apps/labrinth/src/models/exp/minecraft.rs | 151 +-- apps/labrinth/src/models/v2/projects.rs | 34 +- .../src/routes/v2/project_creation.rs | 44 +- apps/labrinth/src/routes/v2/projects.rs | 36 +- apps/labrinth/src/routes/v2/tags.rs | 101 +- .../src/routes/v2/version_creation.rs | 154 ++- apps/labrinth/src/routes/v2/versions.rs | 61 +- apps/labrinth/src/routes/v2_reroute.rs | 31 +- .../src/routes/v3/project_creation.rs | 13 +- .../src/routes/v3/version_creation.rs | 72 +- apps/labrinth/src/routes/v3/version_file.rs | 62 +- apps/labrinth/src/routes/v3/versions.rs | 80 +- apps/labrinth/src/search/indexing.rs | 180 +--- 47 files changed, 1434 insertions(+), 2170 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json delete mode 100644 apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json delete mode 100644 apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json delete mode 100644 apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json delete mode 100644 apps/labrinth/.sqlx/query-282ec0ee4a4ef739fc24af40d2c9be2a335ea9a32a1d19a450aef02f1c58334e.json create mode 100644 apps/labrinth/.sqlx/query-2c3c38f8129fa0792ee1c6c4606a103e271ec688ffb52f7317823ecdd8eafa0f.json delete mode 100644 apps/labrinth/.sqlx/query-4adde8c379e86b12c0b372beadd97ca77a041056b1828f897dd3c063b6cc33db.json delete mode 100644 apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json delete mode 100644 apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json create mode 100644 apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json delete mode 100644 apps/labrinth/.sqlx/query-5cf400329120effe0eb12416a7c4558383398ce3229acff187324d8752f4eb72.json create mode 100644 apps/labrinth/.sqlx/query-7314e6803e4e2eeb03d63b125a6f3d94bd7e6b2a47629291d86a2e7268b75a92.json create mode 100644 apps/labrinth/.sqlx/query-7930f84e1e3155ae638ba05b0cdf5698f2d50aeea16526a8440a739c7886be7f.json delete mode 100644 apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json delete mode 100644 apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json delete mode 100644 apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json delete mode 100644 apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json create mode 100644 apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json create mode 100644 apps/labrinth/.sqlx/query-a0ce31766162d74a7b8c2fc2b0ee988a4ee9773eb26947c54a5859a2b80bcd61.json delete mode 100644 apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json delete mode 100644 apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json delete mode 100644 apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json delete mode 100644 apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json create mode 100644 apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json delete mode 100644 apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json delete mode 100644 apps/labrinth/.sqlx/query-dde28342216bc4f81887ce7f75d049676aa23656464363293664c5066f164a07.json create mode 100644 apps/labrinth/.sqlx/query-ea65f977e55e7d974162fc4dabf636e53a8d55578ce61c364626bd5bc3341519.json delete mode 100644 apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json diff --git a/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json b/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json deleted file mode 100644 index 3e046f329c..0000000000 --- a/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games,\n ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "version_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 3, - "name": "games", - "type_info": "VarcharArray" - }, - { - "ordinal": 4, - "name": "loader_fields", - "type_info": "Int4Array" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - null, - null, - null, - null - ] - }, - "hash": "00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d" -} diff --git a/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json b/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json deleted file mode 100644 index 33e8733d62..0000000000 --- a/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT mod_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games,\n ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id\n WHERE v.id = ANY($1)\n GROUP BY mod_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 3, - "name": "games", - "type_info": "VarcharArray" - }, - { - "ordinal": 4, - "name": "loader_fields", - "type_info": "Int4Array" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - null, - null, - null, - null - ] - }, - "hash": "0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8" -} diff --git a/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json b/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json deleted file mode 100644 index b2cf2a4c63..0000000000 --- a/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT version_id, field_id, int_value, enum_value, string_value\n FROM version_fields\n WHERE version_id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "version_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "field_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "int_value", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "enum_value", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "string_value", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - true, - false, - true - ] - }, - "hash": "10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51" -} diff --git a/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json b/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json deleted file mode 100644 index f7c409af75..0000000000 --- a/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, enum_id, value, ordering,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\",\n created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "enum_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "ty?", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "major?", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "created", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int4Array" - ] - }, - "nullable": [ - false, - false, - false, - true, - null, - null, - false - ] - }, - "hash": "214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd" -} diff --git a/apps/labrinth/.sqlx/query-282ec0ee4a4ef739fc24af40d2c9be2a335ea9a32a1d19a450aef02f1c58334e.json b/apps/labrinth/.sqlx/query-282ec0ee4a4ef739fc24af40d2c9be2a335ea9a32a1d19a450aef02f1c58334e.json deleted file mode 100644 index 3d7252d4b2..0000000000 --- a/apps/labrinth/.sqlx/query-282ec0ee4a4ef739fc24af40d2c9be2a335ea9a32a1d19a450aef02f1c58334e.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable\n FROM loader_field_enums lfe\n WHERE lfe.enum_name = $1\n ORDER BY lfe.ordering ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "enum_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "hidable", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false - ] - }, - "hash": "282ec0ee4a4ef739fc24af40d2c9be2a335ea9a32a1d19a450aef02f1c58334e" -} diff --git a/apps/labrinth/.sqlx/query-2c3c38f8129fa0792ee1c6c4606a103e271ec688ffb52f7317823ecdd8eafa0f.json b/apps/labrinth/.sqlx/query-2c3c38f8129fa0792ee1c6c4606a103e271ec688ffb52f7317823ecdd8eafa0f.json new file mode 100644 index 0000000000..c430e2ecc0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2c3c38f8129fa0792ee1c6c4606a103e271ec688ffb52f7317823ecdd8eafa0f.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id, v.mod_id, v.date_published,\n\t\t\tv.components AS \"components!: sqlx::types::Json\"\n FROM versions v\n WHERE mod_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "components!: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "2c3c38f8129fa0792ee1c6c4606a103e271ec688ffb52f7317823ecdd8eafa0f" +} diff --git a/apps/labrinth/.sqlx/query-4adde8c379e86b12c0b372beadd97ca77a041056b1828f897dd3c063b6cc33db.json b/apps/labrinth/.sqlx/query-4adde8c379e86b12c0b372beadd97ca77a041056b1828f897dd3c063b6cc33db.json deleted file mode 100644 index a42acfc298..0000000000 --- a/apps/labrinth/.sqlx/query-4adde8c379e86b12c0b372beadd97ca77a041056b1828f897dd3c063b6cc33db.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT q.hash AS \"hash!\", latest.id AS \"version_id!\"\n FROM JSONB_TO_RECORDSET($1::JSONB) AS q(\n hash TEXT,\n project_id BIGINT,\n loaders VARCHAR[],\n version_types VARCHAR[],\n loader_fields JSONB\n )\n CROSS JOIN LATERAL (\n SELECT v.id\n FROM versions v\n WHERE v.mod_id = q.project_id\n AND v.status = ANY($2::VARCHAR[])\n AND (q.version_types IS NULL OR v.version_type = ANY(q.version_types))\n AND (\n q.loaders IS NULL\n OR EXISTS (\n SELECT 1 FROM loaders_versions lv\n INNER JOIN loaders l ON l.id = lv.loader_id\n WHERE lv.version_id = v.id AND l.loader = ANY(q.loaders)\n )\n )\n AND NOT EXISTS (\n SELECT 1\n FROM JSONB_EACH(q.loader_fields) AS req(field, vals)\n INNER JOIN loader_fields lf ON lf.field = req.field\n WHERE EXISTS (\n SELECT 1 FROM loaders_versions lv\n INNER JOIN loader_fields_loaders lfl ON lfl.loader_id = lv.loader_id\n WHERE lv.version_id = v.id AND lfl.loader_field_id = lf.id\n )\n AND (\n lf.field_type IN ('array_integer', 'array_text', 'array_boolean', 'array_enum')\n OR (\n SELECT COUNT(*) FROM version_fields vf\n WHERE vf.version_id = v.id AND vf.field_id = lf.id\n ) = 1\n )\n AND NOT EXISTS (\n SELECT 1\n FROM version_fields vf\n LEFT JOIN loader_field_enum_values lfev ON lfev.id = vf.enum_value\n CROSS JOIN JSONB_ARRAY_ELEMENTS(req.vals) AS want(val)\n WHERE vf.version_id = v.id AND vf.field_id = lf.id\n AND CASE\n WHEN lf.field_type IN ('enum', 'array_enum')\n THEN JSONB_TYPEOF(want.val) = 'string' AND lfev.value = want.val #>> '{}'\n WHEN lf.field_type IN ('text', 'array_text')\n THEN JSONB_TYPEOF(want.val) = 'string' AND vf.string_value = want.val #>> '{}'\n WHEN lf.field_type IN ('integer', 'array_integer')\n THEN JSONB_TYPEOF(want.val) = 'number' AND vf.int_value = (want.val #>> '{}')::NUMERIC\n WHEN lf.field_type IN ('boolean', 'array_boolean')\n THEN JSONB_TYPEOF(want.val) = 'boolean' AND (vf.int_value <> 0) = (want.val #>> '{}')::BOOLEAN\n END\n )\n )\n ORDER BY v.ordering DESC NULLS FIRST, v.date_published DESC, v.id DESC\n LIMIT 1\n ) latest\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "hash!", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "version_id!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Jsonb", - "VarcharArray" - ] - }, - "nullable": [ - null, - false - ] - }, - "hash": "4adde8c379e86b12c0b372beadd97ca77a041056b1828f897dd3c063b6cc33db" -} diff --git a/apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json b/apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json deleted file mode 100644 index 28d1860e53..0000000000 --- a/apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM version_fields\n WHERE version_id = $1\n AND field_id = ANY($2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int4Array" - ] - }, - "nullable": [] - }, - "hash": "527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105" -} diff --git a/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json b/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json deleted file mode 100644 index 5518032577..0000000000 --- a/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT version_id, field_id, int_value, enum_value, string_value\n FROM version_fields\n WHERE version_id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "version_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "field_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "int_value", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "enum_value", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "string_value", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - true, - false, - true - ] - }, - "hash": "53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542" -} diff --git a/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json b/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json new file mode 100644 index 0000000000..826115438d --- /dev/null +++ b/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "games", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null, + null + ] + }, + "hash": "5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354" +} diff --git a/apps/labrinth/.sqlx/query-5cf400329120effe0eb12416a7c4558383398ce3229acff187324d8752f4eb72.json b/apps/labrinth/.sqlx/query-5cf400329120effe0eb12416a7c4558383398ce3229acff187324d8752f4eb72.json deleted file mode 100644 index 57f3a130e3..0000000000 --- a/apps/labrinth/.sqlx/query-5cf400329120effe0eb12416a7c4558383398ce3229acff187324d8752f4eb72.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT latest.id AS \"id!\"\n FROM mods m\n CROSS JOIN LATERAL (\n SELECT\n $7::BOOL\n OR EXISTS (\n SELECT 1 FROM team_members tm\n WHERE tm.team_id = m.team_id AND tm.user_id = $8::BIGINT\n )\n OR EXISTS (\n SELECT 1 FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id\n WHERE o.id = m.organization_id AND tm.user_id = $8::BIGINT\n ) AS full_access\n ) access\n CROSS JOIN LATERAL (\n SELECT v.id\n FROM versions v\n WHERE v.mod_id = m.id\n AND (CARDINALITY($4::VARCHAR[]) = 0 OR v.version_type = ANY($4))\n AND EXISTS (\n SELECT 1 FROM version_fields vf\n INNER JOIN loader_field_enum_values lfev ON lfev.id = vf.enum_value\n WHERE vf.version_id = v.id AND vf.field_id = 3\n AND (CARDINALITY($2::VARCHAR[]) = 0 OR lfev.value = ANY($2))\n )\n AND EXISTS (\n SELECT 1 FROM loaders_versions lv\n INNER JOIN loaders l ON l.id = lv.loader_id\n WHERE lv.version_id = v.id\n AND (CARDINALITY($3::VARCHAR[]) = 0 OR l.loader = ANY($3))\n )\n AND (\n access.full_access\n OR (\n v.status = ANY($5::VARCHAR[])\n AND NOT EXISTS (\n SELECT 1\n FROM project_attribution_groups pag\n INNER JOIN project_attribution_files paf ON paf.group_id = pag.id\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n INNER JOIN files f ON f.id = ofs.file_id\n INNER JOIN attribution_enforced_versions aev ON aev.id = f.version_id\n WHERE pag.project_id = v.mod_id\n AND f.version_id = v.id\n AND (\n pag.attribution IS NULL\n OR pag.attribution->>'kind' = 'no_permission'\n OR COALESCE(pag.attribution->'moderation_status'->>'kind', 'approved') != 'approved'\n )\n )\n )\n )\n ORDER BY v.date_published DESC, v.id DESC\n LIMIT 1\n ) latest\n WHERE m.id = ANY($1)\n AND (access.full_access OR m.status = ANY($6::VARCHAR[]))\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8Array", - "VarcharArray", - "VarcharArray", - "VarcharArray", - "VarcharArray", - "VarcharArray", - "Bool", - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "5cf400329120effe0eb12416a7c4558383398ce3229acff187324d8752f4eb72" -} diff --git a/apps/labrinth/.sqlx/query-7314e6803e4e2eeb03d63b125a6f3d94bd7e6b2a47629291d86a2e7268b75a92.json b/apps/labrinth/.sqlx/query-7314e6803e4e2eeb03d63b125a6f3d94bd7e6b2a47629291d86a2e7268b75a92.json new file mode 100644 index 0000000000..506c555c8d --- /dev/null +++ b/apps/labrinth/.sqlx/query-7314e6803e4e2eeb03d63b125a6f3d94bd7e6b2a47629291d86a2e7268b75a92.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT latest.id AS \"id!\"\n FROM mods m\n CROSS JOIN LATERAL (\n SELECT\n $7::BOOL\n OR EXISTS (\n SELECT 1 FROM team_members tm\n WHERE tm.team_id = m.team_id AND tm.user_id = $8::BIGINT\n )\n OR EXISTS (\n SELECT 1 FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id\n WHERE o.id = m.organization_id AND tm.user_id = $8::BIGINT\n ) AS full_access\n ) access\n CROSS JOIN LATERAL (\n SELECT v.id\n FROM versions v\n WHERE v.mod_id = m.id\n AND (CARDINALITY($4::VARCHAR[]) = 0 OR v.version_type = ANY($4))\n AND EXISTS (\n\t\t\t\t\tSELECT 1\n\t\t\t\t\tFROM JSONB_ARRAY_ELEMENTS_TEXT(v.components->'minecraft_mod'->'game_versions') AS gv(value)\n\t\t\t\t\tWHERE CARDINALITY($2::VARCHAR[]) = 0 OR gv.value = ANY($2)\n )\n AND EXISTS (\n SELECT 1 FROM loaders_versions lv\n INNER JOIN loaders l ON l.id = lv.loader_id\n WHERE lv.version_id = v.id\n AND (CARDINALITY($3::VARCHAR[]) = 0 OR l.loader = ANY($3))\n )\n AND (\n access.full_access\n OR (\n v.status = ANY($5::VARCHAR[])\n AND NOT EXISTS (\n SELECT 1\n FROM project_attribution_groups pag\n INNER JOIN project_attribution_files paf ON paf.group_id = pag.id\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n INNER JOIN files f ON f.id = ofs.file_id\n INNER JOIN attribution_enforced_versions aev ON aev.id = f.version_id\n WHERE pag.project_id = v.mod_id\n AND f.version_id = v.id\n AND (\n pag.attribution IS NULL\n OR pag.attribution->>'kind' = 'no_permission'\n OR COALESCE(pag.attribution->'moderation_status'->>'kind', 'approved') != 'approved'\n )\n )\n )\n )\n ORDER BY v.date_published DESC, v.id DESC\n LIMIT 1\n ) latest\n WHERE m.id = ANY($1)\n AND (access.full_access OR m.status = ANY($6::VARCHAR[]))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "VarcharArray", + "VarcharArray", + "VarcharArray", + "VarcharArray", + "Bool", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7314e6803e4e2eeb03d63b125a6f3d94bd7e6b2a47629291d86a2e7268b75a92" +} diff --git a/apps/labrinth/.sqlx/query-7930f84e1e3155ae638ba05b0cdf5698f2d50aeea16526a8440a739c7886be7f.json b/apps/labrinth/.sqlx/query-7930f84e1e3155ae638ba05b0cdf5698f2d50aeea16526a8440a739c7886be7f.json new file mode 100644 index 0000000000..38c80643d4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7930f84e1e3155ae638ba05b0cdf5698f2d50aeea16526a8440a739c7886be7f.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id, v.id as id, date_published,\n v.components AS \"components: sqlx::types::Json\"\n FROM mods m\n INNER JOIN versions v ON m.id = v.mod_id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "7930f84e1e3155ae638ba05b0cdf5698f2d50aeea16526a8440a739c7886be7f" +} diff --git a/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json b/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json deleted file mode 100644 index d0bc6abfc1..0000000000 --- a/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "enum_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "ty?", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "major?", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int4Array" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - null, - null - ] - }, - "hash": "83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65" -} diff --git a/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json b/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json deleted file mode 100644 index 8f42b4414b..0000000000 --- a/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type\n FROM loader_fields lf\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "field", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "field_type", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "optional", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "min_val", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "max_val", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "enum_type", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true - ] - }, - "hash": "846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4" -} diff --git a/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json b/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json deleted file mode 100644 index abe6d4217d..0000000000 --- a/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional\n FROM loader_fields lf\n WHERE id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "field", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "field_type", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "enum_type", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "min_val", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "max_val", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "optional", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int4Array" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - true, - false - ] - }, - "hash": "887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c" -} diff --git a/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json b/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json deleted file mode 100644 index 874d0bc04b..0000000000 --- a/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int8Array", - "Int4Array", - "TextArray", - "Int4Array" - ] - }, - "nullable": [] - }, - "hash": "8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d" -} diff --git a/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json b/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json new file mode 100644 index 0000000000..62e6a58c30 --- /dev/null +++ b/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n WHERE v.id = ANY($1)\n GROUP BY mod_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "games", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null, + null + ] + }, + "hash": "977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287" +} diff --git a/apps/labrinth/.sqlx/query-a0ce31766162d74a7b8c2fc2b0ee988a4ee9773eb26947c54a5859a2b80bcd61.json b/apps/labrinth/.sqlx/query-a0ce31766162d74a7b8c2fc2b0ee988a4ee9773eb26947c54a5859a2b80bcd61.json new file mode 100644 index 0000000000..1f8811d529 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a0ce31766162d74a7b8c2fc2b0ee988a4ee9773eb26947c54a5859a2b80bcd61.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT q.hash AS \"hash!\", latest.id AS \"version_id!\"\n FROM JSONB_TO_RECORDSET($1::JSONB) AS q(\n hash TEXT,\n project_id BIGINT,\n loaders VARCHAR[],\n version_types VARCHAR[],\n loader_fields JSONB\n )\n CROSS JOIN LATERAL (\n SELECT v.id\n FROM versions v\n WHERE v.mod_id = q.project_id\n AND v.status = ANY($2::VARCHAR[])\n AND (q.version_types IS NULL OR v.version_type = ANY(q.version_types))\n AND (\n q.loaders IS NULL\n OR EXISTS (\n SELECT 1 FROM loaders_versions lv\n INNER JOIN loaders l ON l.id = lv.loader_id\n WHERE lv.version_id = v.id AND l.loader = ANY(q.loaders)\n )\n )\n AND NOT EXISTS (\n SELECT 1\n FROM JSONB_EACH(q.loader_fields) AS req(field, vals)\n\t\t\t\t\tWHERE req.field IN ('game_versions', 'mrpack_loaders', 'environment')\n\t\t\t\t\t\tAND v.components->'minecraft_mod' IS NOT NULL\n\t\t\t\t\t\tAND v.components->'minecraft_mod' <> 'null'::JSONB\n\t\t\t\t\t\tAND (\n\t\t\t\t\t\t\treq.field <> 'environment'\n\t\t\t\t\t\t\tOR COALESCE(JSONB_TYPEOF(v.components->'minecraft_mod'->'environment') = 'string', FALSE)\n\t\t\t\t\t\t)\n\t\t\t\t\t\tAND NOT EXISTS (\n\t\t\t\t\t\t\tSELECT 1\n\t\t\t\t\t\t\tFROM JSONB_ARRAY_ELEMENTS(req.vals) AS want(val)\n\t\t\t\t\t\t\tWHERE JSONB_TYPEOF(want.val) = 'string'\n\t\t\t\t\t\t\t\tAND CASE WHEN req.field = 'environment' THEN\n\t\t\t\t\t\t\t\t\tv.components->'minecraft_mod'->'environment' = want.val\n\t\t\t\t\t\t\t\tELSE\n\t\t\t\t\t\t\t\t\tCOALESCE(v.components->'minecraft_mod'->req.field, '[]'::JSONB) @> JSONB_BUILD_ARRAY(want.val)\n\t\t\t\t\t\t\t\tEND\n\t\t\t\t\t\t)\n )\n ORDER BY v.ordering DESC NULLS FIRST, v.date_published DESC, v.id DESC\n LIMIT 1\n ) latest\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "version_id!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb", + "VarcharArray" + ] + }, + "nullable": [ + null, + false + ] + }, + "hash": "a0ce31766162d74a7b8c2fc2b0ee988a4ee9773eb26947c54a5859a2b80bcd61" +} diff --git a/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json b/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json deleted file mode 100644 index 897894b7a2..0000000000 --- a/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM version_fields vf\n WHERE vf.version_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0" -} diff --git a/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json b/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json deleted file mode 100644 index 2f1c614db8..0000000000 --- a/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "enum_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "ty?", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "major?", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - true, - false, - null, - null - ] - }, - "hash": "a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8" -} diff --git a/apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json b/apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json deleted file mode 100644 index 99373fb6ad..0000000000 --- a/apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = jsonb_set(\n COALESCE(loader_field_enum_values.metadata, $4),\n '{type}',\n COALESCE($4->'type', loader_field_enum_values.metadata->'type')\n ),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Int4", - "Varchar", - "Timestamp", - "Jsonb" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276" -} diff --git a/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json b/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json deleted file mode 100644 index f7b9866aa5..0000000000 --- a/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, lfl.loader_id\n FROM loader_fields lf\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_field_id = lf.id\n WHERE lfl.loader_id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "field", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "field_type", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "optional", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "min_val", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "max_val", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "enum_type", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "loader_id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Int4Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - true, - false - ] - }, - "hash": "c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c" -} diff --git a/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json b/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json new file mode 100644 index 0000000000..4b2b7b2f3a --- /dev/null +++ b/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\t\tUPDATE versions SET components = jsonb_set(\n\t\t\t\t\tCOALESCE(components, '{}'::jsonb), '{minecraft_mod}',\n\t\t\t\t\tjsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb)\n\t\t\t\t\t\t|| COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb)\n\t\t\t\t\t\t|| $2::jsonb\n\t\t\t\t) WHERE id = $1\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d" +} diff --git a/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json b/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json deleted file mode 100644 index 859e603fe7..0000000000 --- a/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional\n FROM loader_fields lf\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "field", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "field_type", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "enum_type", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "min_val", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "max_val", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "optional", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - true, - true, - true, - false - ] - }, - "hash": "d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c" -} diff --git a/apps/labrinth/.sqlx/query-dde28342216bc4f81887ce7f75d049676aa23656464363293664c5066f164a07.json b/apps/labrinth/.sqlx/query-dde28342216bc4f81887ce7f75d049676aa23656464363293664c5066f164a07.json deleted file mode 100644 index 5a9a07f554..0000000000 --- a/apps/labrinth/.sqlx/query-dde28342216bc4f81887ce7f75d049676aa23656464363293664c5066f164a07.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.mod_id, MIN(vf.version_id) AS \"version_id!\", vf.field_id, vf.int_value, vf.enum_value, vf.string_value\n FROM versions v\n INNER JOIN version_fields vf ON v.id = vf.version_id\n WHERE v.id = ANY($1) AND vf.version_id = ANY($1)\n GROUP BY v.mod_id, vf.field_id, vf.int_value, vf.enum_value, vf.string_value\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "version_id!", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "field_id", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "int_value", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "enum_value", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "string_value", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - null, - false, - true, - false, - true - ] - }, - "hash": "dde28342216bc4f81887ce7f75d049676aa23656464363293664c5066f164a07" -} diff --git a/apps/labrinth/.sqlx/query-ea65f977e55e7d974162fc4dabf636e53a8d55578ce61c364626bd5bc3341519.json b/apps/labrinth/.sqlx/query-ea65f977e55e7d974162fc4dabf636e53a8d55578ce61c364626bd5bc3341519.json new file mode 100644 index 0000000000..463ddd5946 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ea65f977e55e7d974162fc4dabf636e53a8d55578ce61c364626bd5bc3341519.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET components = jsonb_set(COALESCE(components, '{}'::jsonb), '{minecraft_mod}', $2::jsonb)\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "ea65f977e55e7d974162fc4dabf636e53a8d55578ce61c364626bd5bc3341519" +} diff --git a/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json b/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json deleted file mode 100644 index 478c4dcd62..0000000000 --- a/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "enum_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "ty?", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "major?", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int4Array" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - null, - null - ] - }, - "hash": "ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718" -} diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index a005cec8d3..38ea26e862 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -306,10 +306,10 @@ pub async fn ping_minecraft_java_servers( } mod version_updater { - use std::sync::LazyLock; - use crate::database::PgPool; - use crate::database::models::legacy_loader_fields::MinecraftGameVersion; + use crate::database::models::legacy_loader_fields::{ + MinecraftGameVersion, HALL_OF_SHAME, HALL_OF_SHAME_2, + }; use chrono::{DateTime, Utc}; use serde::Deserialize; use thiserror::Error; @@ -352,67 +352,6 @@ mod version_updater { let mut skipped_versions_count = 0u32; - // A list of version names that contains spaces. - // Generated using the command - // ```sh - // curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ - // | jq '[.versions[].id | select(contains(" "))]' - // ``` - const HALL_OF_SHAME: [(&str, &str); 12] = [ - ("1.14.2 Pre-Release 4", "1.14.2-pre4"), - ("1.14.2 Pre-Release 3", "1.14.2-pre3"), - ("1.14.2 Pre-Release 2", "1.14.2-pre2"), - ("1.14.2 Pre-Release 1", "1.14.2-pre1"), - ("1.14.1 Pre-Release 2", "1.14.1-pre2"), - ("1.14.1 Pre-Release 1", "1.14.1-pre1"), - ("1.14 Pre-Release 5", "1.14-pre5"), - ("1.14 Pre-Release 4", "1.14-pre4"), - ("1.14 Pre-Release 3", "1.14-pre3"), - ("1.14 Pre-Release 2", "1.14-pre2"), - ("1.14 Pre-Release 1", "1.14-pre1"), - ("3D Shareware v1.34", "3D-Shareware-v1.34"), - ]; - - /// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, - /// as most of our ordering logic is with DateTime - static HALL_OF_SHAME_2: LazyLock<[(&'static str, DateTime); 4]> = - LazyLock::new(|| { - [ - ( - "1.4.5", - chrono::DateTime::parse_from_rfc3339( - "2012-12-19T22:00:00+00:00", - ) - .unwrap() - .into(), - ), - ( - "1.4.6", - chrono::DateTime::parse_from_rfc3339( - "2012-12-19T22:00:01+00:00", - ) - .unwrap() - .into(), - ), - ( - "1.6.3", - chrono::DateTime::parse_from_rfc3339( - "2013-09-13T10:54:41+00:00", - ) - .unwrap() - .into(), - ), - ( - "13w37b", - chrono::DateTime::parse_from_rfc3339( - "2013-09-13T10:54:42+00:00", - ) - .unwrap() - .into(), - ), - ] - }); - for version in input.versions.into_iter() { let mut name = version.id; if !name diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs index 95ac9c01be..86e022e604 100644 --- a/apps/labrinth/src/database/models/legacy_loader_fields.rs +++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs @@ -4,21 +4,85 @@ // for fetching data from forge, maven, etc. // These fields only apply to minecraft-java, and are hardcoded to the minecraft-java game. +use std::sync::LazyLock; + use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr}; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use serde_json::json; use xredis::RedisPool; use super::{ - LoaderFieldEnumValueId, + LoaderFieldEnumId, LoaderFieldEnumValueId, loader_fields::{ LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue, }, }; +/// Mojang version names that require aliases for historical Labrinth compatibility. +// A list of version names that contains spaces. +// Generated using the command +// ```sh +// curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ +// | jq '[.versions[].id | select(contains(" "))]' +// ``` +pub(crate) const HALL_OF_SHAME: [(&str, &str); 12] = [ + ("1.14.2 Pre-Release 4", "1.14.2-pre4"), + ("1.14.2 Pre-Release 3", "1.14.2-pre3"), + ("1.14.2 Pre-Release 2", "1.14.2-pre2"), + ("1.14.2 Pre-Release 1", "1.14.2-pre1"), + ("1.14.1 Pre-Release 2", "1.14.1-pre2"), + ("1.14.1 Pre-Release 1", "1.14.1-pre1"), + ("1.14 Pre-Release 5", "1.14-pre5"), + ("1.14 Pre-Release 4", "1.14-pre4"), + ("1.14 Pre-Release 3", "1.14-pre3"), + ("1.14 Pre-Release 2", "1.14-pre2"), + ("1.14 Pre-Release 1", "1.14-pre1"), + ("3D Shareware v1.34", "3D-Shareware-v1.34"), +]; + +/// Preserves historical Labrinth aliases while leaving other catalog names unchanged. +pub(crate) fn legacy_game_version_name(name: &str) -> &str { + HALL_OF_SHAME + .iter() + .find_map(|(canonical, legacy)| (*canonical == name).then_some(*legacy)) + .unwrap_or(name) +} + +/// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, +/// as most of our ordering logic is with DateTime +pub(crate) static HALL_OF_SHAME_2: LazyLock<[(&str, DateTime); 4]> = + LazyLock::new(|| { + [ + ("1.4.5", "2012-12-19T22:00:00+00:00"), + ("1.4.6", "2012-12-19T22:00:01+00:00"), + ("1.6.3", "2013-09-13T10:54:41+00:00"), + ("13w37b", "2013-09-13T10:54:42+00:00"), + ] + .map(|(version, date)| { + ( + version, + DateTime::parse_from_rfc3339(date) + .unwrap() + .with_timezone(&Utc), + ) + }) + }); + +/// Preserves the historical ordering of releases with identical upstream timestamps. +pub(crate) fn legacy_game_version_date( + name: &str, + date: DateTime, +) -> DateTime { + HALL_OF_SHAME_2 + .iter() + .find_map(|(version, corrected)| { + (*version == name).then_some(*corrected) + }) + .unwrap_or(date) +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct MinecraftGameVersion { pub id: LoaderFieldEnumValueId, @@ -168,66 +232,35 @@ impl<'a> MinecraftGameVersionBuilder<'a> { } } - pub async fn insert<'b, E>( - self, - exec: E, - redis: &RedisPool, - ) -> Result - where - E: crate::database::Executor<'b, Database = sqlx::Postgres> + Copy, - { - let game_versions_enum = - LoaderFieldEnum::get("game_versions", exec, redis) - .await - .wrap_err("fetching game versions loader field")? - .ok_or_else(|| { - eyre::eyre!("Missing loaders field: 'game_versions'") - }) - .wrap_err("finding game versions loader field")?; - - // Get enum id for game versions - let metadata = json!({ - "type": self.version_type, - "major": false - }); - - // This looks like a mess, but it *should* work - // This allows game versions to be partially updated without - // replacing the unspecified fields with defaults. - let result = sqlx::query!( - " - INSERT INTO loader_field_enum_values (enum_id, value, created, metadata) - VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4) - ON CONFLICT (enum_id, value) DO UPDATE - SET metadata = jsonb_set( - COALESCE(loader_field_enum_values.metadata, $4), - '{type}', - COALESCE($4->'type', loader_field_enum_values.metadata->'type') - ), - created = COALESCE($3, loader_field_enum_values.created) - RETURNING id - ", - game_versions_enum.id.0, - self.version, - self.date.map(chrono::DateTime::naive_utc), - metadata - ) - .fetch_one(exec) - .await - .wrap_err("inserting game version")?; - - let mut conn = redis - .connect() - .await - .wrap_err("connecting to redis to clear game version cache")?; - let key = conn.key().entity( - crate::database::models::loader_fields::LOADER_FIELD_ENUM_VALUES_NAMESPACE, - game_versions_enum.id.0, - ); - conn.delete(&key) - .await - .wrap_err("clearing cached game versions")?; - - Ok(LoaderFieldEnumValueId(result.id)) - } + /// Verifies an entry owned by the metadata generator; never creates incomplete catalog rows. + pub async fn insert<'b, E>( + self, + exec: E, + redis: &RedisPool, + ) -> Result + where + E: crate::database::Executor<'b, Database = sqlx::Postgres> + Copy, + { + let version = self + .version + .ok_or_else(|| eyre::eyre!("missing Minecraft version name"))?; + let values = + LoaderFieldEnumValue::list(LoaderFieldEnumId(1), exec, redis) + .await?; + let existing = values.into_iter().find(|value| value.value == version) + .ok_or_else(|| eyre::eyre!("Minecraft version `{version}` is absent from the metadata catalog; ingest it with the metadata generator"))?; + if let Some(ty) = self.version_type { + eyre::ensure!( + existing.ty.as_deref() == Some(ty), + "Minecraft version `{version}` type differs from the metadata catalog; update it with the metadata generator" + ); + } + if let Some(date) = self.date { + eyre::ensure!( + existing.created == *date, + "Minecraft version `{version}` release date differs from the metadata catalog; update it with the metadata generator" + ); + } + Ok(existing.id) + } } diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs index 4ddbd90e4b..d8e2c28597 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -5,7 +5,6 @@ use super::ids::*; use crate::database::PgTransaction; use chrono::DateTime; use chrono::Utc; -use dashmap::DashMap; use eyre::{Result, WrapErr}; use futures::TryStreamExt; use itertools::Itertools; @@ -15,12 +14,6 @@ use xredis::RedisPool; const GAMES_LIST_NAMESPACE: &str = "games:v4"; const LOADER_ID: &str = "loader_id:v4"; const LOADERS_LIST_NAMESPACE: &str = "loaders:v4"; -const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v4"; -const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v4"; -const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v4"; -pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = - "loader_field_enum_values:v4"; - #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Game { pub id: GameId, @@ -402,465 +395,521 @@ pub struct QueryLoaderFieldEnumValue { pub major: Option, } -impl LoaderField { - pub async fn get_field<'a, E>( - field: &str, - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let fields = Self::get_fields(loader_ids, exec, redis) - .await - .wrap_err("fetching loader fields")?; - Ok(fields.into_iter().find(|f| f.field == field)) - } - - // Gets all fields for a given loader(s) - // Returns all as this there are probably relatively few fields per loader - pub async fn get_fields<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let found_loader_fields = - Self::get_fields_per_loader(loader_ids, exec, redis) - .await - .wrap_err("fetching loader fields by loader")?; - let result = found_loader_fields - .into_values() - .flatten() - .unique_by(|x| x.id) - .collect(); - Ok(result) - } - - pub async fn get_fields_per_loader<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let val = redis.get_cached_keys_raw( - LOADER_FIELDS_NAMESPACE, - &loader_ids.iter().map(|x| x.0).collect::>(), - |loader_ids| async move { - let result = sqlx::query!( - " - SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, lfl.loader_id - FROM loader_fields lf - LEFT JOIN loader_fields_loaders lfl ON lfl.loader_field_id = lf.id - WHERE lfl.loader_id = ANY($1) - ", - &loader_ids, - ) - .fetch(exec) - .map_err(eyre::Report::from) - .try_fold(DashMap::new(), |acc: DashMap>, r| { - if let Some(field_type) = LoaderFieldType::build(&r.field_type, r.enum_type) { - let loader_field = LoaderField { - id: LoaderFieldId(r.id), - field_type, - field: r.field, - optional: r.optional, - min_val: r.min_val, - max_val: r.max_val, - }; - - acc.entry(r.loader_id) - .or_default() - .push(loader_field); - } - - async move { eyre::Ok(acc) } - }) - .await - .wrap_err("fetching loader fields")?; - - eyre::Ok(result) - }, - ) - .await - .wrap_err("fetching cached loader fields")?; - - Ok(val.into_iter().map(|x| (LoaderId(x.0), x.1)).collect()) - } - - // Gets all fields for a given loader(s) - // This is for tags, which need all fields for all loaders - // We want to return them even in testing situations where we dont have loaders or loader_fields_loaders set up - pub async fn get_fields_all<'a, E>( - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached loader fields")?; - let key = redis.key().metadata(LOADER_FIELDS_NAMESPACE_ALL, ""); - - let cached_fields: Option> = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached loader fields")?; - - if let Some(cached_fields) = cached_fields { - return Ok(cached_fields); - } - } - - let result = sqlx::query!( - " - SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type - FROM loader_fields lf - ", - ) - .fetch(exec) - .map_ok(|r| { - LoaderFieldType::build(&r.field_type, r.enum_type).map(|field_type| { - LoaderField { - id: LoaderFieldId(r.id), - field_type, - field: r.field, - optional: r.optional, - min_val: r.min_val, - max_val: r.max_val, - } - }) - }) - .try_collect::>>() - .await - .wrap_err("fetching all loader fields")? - .into_iter() - .flatten() - .collect(); - - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache loader fields")?; - let key = redis.key().metadata(LOADER_FIELDS_NAMESPACE_ALL, ""); - - redis - .set_serialized(&key, &result, None) - .await - .wrap_err("caching loader fields")?; - - Ok(result) - } +/// Compatibility IDs are schema identifiers, not IDs from the retired tables. +fn minecraft_fields() -> Vec { + [ + (1, "game_versions", false, true), + (2, "environment", true, false), + (3, "mrpack_loaders", true, true), + ] + .into_iter() + .map(|(id, field, optional, array)| LoaderField { + id: LoaderFieldId(id), + field: field.into(), + optional, + field_type: if array { + LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) + } else { + LoaderFieldType::Enum(LoaderFieldEnumId(id)) + }, + min_val: if id == 1 { Some(1) } else { None }, + max_val: None, + }) + .collect() } -impl LoaderFieldEnum { - pub async fn get<'a, E>( - enum_name: &str, // Note: NOT loader field name - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached loader field enum")?; - let key = redis - .key() - .metadata(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name); - let cached_enum = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached loader field enum")?; - if let Some(cached_enum) = cached_enum { - return Ok(cached_enum); - } - } +static MINECRAFT_METADATA: std::sync::LazyLock< + tokio::sync::OnceCell>, +> = std::sync::LazyLock::new(tokio::sync::OnceCell::new); + +/// Register the application's existing metadata service before compatibility lookups. +/// Call once during setup with `minecraft_meta_db.clone()`. +pub fn register_minecraft_metadata( + metadata: actix_web::web::Data, +) -> Result<()> { + MINECRAFT_METADATA.set(metadata.into_inner()).map_err(|_| { + eyre::eyre!("Minecraft metadata service is already initialized") + }) +} - let result = sqlx::query!( - " - SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable - FROM loader_field_enums lfe - WHERE lfe.enum_name = $1 - ORDER BY lfe.ordering ASC - ", - enum_name - ) - .fetch_optional(exec) - .await - .wrap_err("fetching loader field enum")? - .map(|l| LoaderFieldEnum { - id: LoaderFieldEnumId(l.id), - enum_name: l.enum_name, - ordering: l.ordering, - hidable: l.hidable, - }); +pub(crate) async fn minecraft_metadata() +-> Result<&'static crate::database::MinecraftMetaDb> { + Ok(MINECRAFT_METADATA + .get_or_try_init(|| async { + crate::database::MinecraftMetaDb::connect() + .await + .map(std::sync::Arc::new) + }) + .await? + .as_ref()) +} - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache loader field enum")?; - let key = redis - .key() - .metadata(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name); +fn compatibility_value(enum_id: i32, value: String) -> LoaderFieldEnumValue { + // FNV-1a is deterministic across processes and catalog ordering changes. + let hash = value.bytes().fold(2166136261u32, |hash, byte| { + (hash ^ u32::from(byte)).wrapping_mul(16777619) + }); + LoaderFieldEnumValue { + id: LoaderFieldEnumValueId((hash & 0x7fff_ffff) as i32), + enum_id: LoaderFieldEnumId(enum_id), + value, + ordering: None, + created: DateTime::::UNIX_EPOCH, + ty: None, + major: None, + } +} - redis - .set_serialized(&key, &result, None) - .await - .wrap_err("caching loader field enum")?; +impl LoaderField { + pub async fn get_field<'a, E>( + field: &str, + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::get_fields(loader_ids, exec, redis) + .await? + .into_iter() + .find(|item| item.field == field)) + } + pub async fn get_fields<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::get_fields_per_loader(loader_ids, exec, redis) + .await? + .into_values() + .flatten() + .unique_by(|field| field.id) + .collect()) + } + pub async fn get_fields_per_loader<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Loader::list(exec, redis) + .await? + .into_iter() + .filter(|loader| loader_ids.contains(&loader.id)) + .map(|loader| { + let fields = minecraft_fields() + .into_iter() + .filter(|field| { + loader + .supported_games + .iter() + .any(|game| game == "minecraft-java") + && (field.field != "mrpack_loaders" + || loader.loader == "mrpack") + }) + .collect(); + (loader.id, fields) + }) + .collect()) + } + pub async fn get_fields_all<'a, E>( + _exec: E, + _redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(minecraft_fields()) + } +} - Ok(result) - } +impl LoaderFieldEnum { + pub async fn get<'a, E>( + enum_name: &str, + _exec: E, + _redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(minecraft_fields() + .into_iter() + .find(|field| field.field == enum_name) + .map(|field| Self { + id: LoaderFieldEnumId(field.id.0), + enum_name: field.field, + ordering: Some(field.id.0), + hidable: field.id.0 == 1, + })) + } } impl LoaderFieldEnumValue { - pub async fn list<'a, E>( - loader_field_enum_id: LoaderFieldEnumId, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list_many(&[loader_field_enum_id], exec, redis) - .await - .wrap_err("fetching loader field enum values")? - .into_iter() - .next() - .map(|x| x.1) - .unwrap_or_default()) - } - - pub async fn list_many_loader_fields<'a, E>( - loader_fields: &[LoaderField], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let get_enum_id = |x: &LoaderField| match x.field_type { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - Some(id) - } - _ => None, - }; - - let enum_ids = loader_fields - .iter() - .filter_map(get_enum_id) - .collect::>(); - let values = Self::list_many(&enum_ids, exec, redis) - .await - .wrap_err("fetching loader field enum values")? - .into_iter() - .collect::>(); - - let mut res = HashMap::new(); - for lf in loader_fields { - if let Some(id) = get_enum_id(lf) { - res.insert( - lf.id, - values.get(&id).unwrap_or(&Vec::new()).to_vec(), - ); - } - } - Ok(res) - } - - pub async fn list_many<'a, E>( - loader_field_enum_ids: &[LoaderFieldEnumId], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let val = redis - .get_cached_keys_raw( - LOADER_FIELD_ENUM_VALUES_NAMESPACE, - &loader_field_enum_ids - .iter() - .map(|x| x.0) - .collect::>(), - |loader_field_enum_ids| async move { - let values = sqlx::query!( - r#" - SELECT id, enum_id, value, ordering, - metadata->>'type' AS "ty?", - (metadata->>'major')::boolean AS "major?", - created FROM loader_field_enum_values - WHERE enum_id = ANY($1) - ORDER BY enum_id, ordering, created DESC - "#, - &loader_field_enum_ids - ) - .fetch(exec) - .map_err(eyre::Report::from) - .try_fold( - DashMap::new(), - |acc: DashMap>, c| { - let value = LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(c.id), - enum_id: LoaderFieldEnumId(c.enum_id), - value: c.value, - ordering: c.ordering, - created: c.created, - ty: c.ty, - major: c.major, - }; - - acc.entry(c.enum_id).or_default().push(value); - - async move { eyre::Ok(acc) } - }, - ) - .await - .wrap_err("fetching loader field enum values")?; - - eyre::Ok(values) - }, - ) - .await - .wrap_err("fetching cached loader field enum values")?; - - Ok(val - .into_iter() - .map(|x| (LoaderFieldEnumId(x.0), x.1)) - .collect()) - } - - // Matches filter against metadata of enum values - pub async fn list_filter<'a, E>( - loader_field_enum_id: LoaderFieldEnumId, - filter: HashMap, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let result = Self::list(loader_field_enum_id, exec, redis) - .await - .wrap_err("fetching loader field enum values to filter")? - .into_iter() - .filter(|x| { - filter.iter().all(|(key, value)| match key.as_str() { - "type" => { - x.ty.as_deref() - .is_some_and(|type_| value.as_str() == Some(type_)) - } - "major" => x - .major - .is_some_and(|major| value.as_bool() == Some(major)), - _ => false, - }) - }) - .collect(); - - Ok(result) - } + /// Fetches authoritative game-version metadata using an explicitly supplied service. + pub async fn minecraft_catalog( + db: &crate::database::MinecraftMetaDb, + ) -> Result> { + let values = { + let mut conn = + db.0.connection() + .await + .wrap_err("acquiring Minecraft metadata connection")?; + minecraft_meta_model::model::MinecraftVersion::all() + .exec(&mut conn) + .await + .wrap_err("fetching Minecraft version catalog")? + .into_iter() + .map(|row| { + let name = row.name.to_string(); + let mut value = compatibility_value( + 1, + super::legacy_loader_fields::legacy_game_version_name(&name).to_owned(), + ); + value.created = DateTime::::from_timestamp( + row.catalog_entry.0.release_time.as_second(), + row.catalog_entry.0.release_time.subsec_nanosecond() + as u32, + ) + .expect("Minecraft timestamp is representable by chrono"); + value.created = super::legacy_loader_fields::legacy_game_version_date(&name, value.created); + use minecraft_meta_model::upstream::mojang::VersionType; + value.ty = Some( + match row.catalog_entry.0.ty { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldAlpha => "alpha", + VersionType::OldBeta => "beta", + } + .to_owned(), + ); + value.major = Some(value.ty.as_deref() == Some("release")); + value + }) + .collect::>() + }; + let mut seen = std::collections::HashSet::new(); + eyre::ensure!( + values.iter().all(|value| seen.insert(value.id)), + "compatibility enum value ID collision" + ); + Ok(values) + } + + pub async fn list<'a, E>( + id: LoaderFieldEnumId, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list_many(&[id], exec, redis) + .await? + .remove(&id) + .unwrap_or_default()) + } + pub async fn list_many_loader_fields<'a, E>( + fields: &[LoaderField], + exec: E, + redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let ids = fields + .iter() + .filter_map(|field| match field.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + Some(id) + } + _ => None, + }) + .collect::>(); + let values = Self::list_many(&ids, exec, redis).await?; + Ok(fields + .iter() + .filter_map(|field| match field.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + Some((field.id, values[&id].clone())) + } + _ => None, + }) + .collect()) + } + pub async fn list_many<'a, E>( + ids: &[LoaderFieldEnumId], + _exec: E, + _redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + use crate::models::exp::minecraft::{Environment, MrpackLoader}; + let mut result = HashMap::new(); + for &id in ids { + let mut values = match id.0 { + 1 => { + Self::minecraft_catalog(minecraft_metadata().await?).await? + } + 2 => [ + Environment::ClientAndServer, + Environment::ClientOnly, + Environment::ClientOnlyServerOptional, + Environment::SingleplayerOnly, + Environment::ServerOnly, + Environment::ServerOnlyClientOptional, + Environment::DedicatedServerOnly, + Environment::ClientOrServer, + Environment::ClientOrServerPrefersBoth, + Environment::Unknown, + ] + .into_iter() + .map(|value| compatibility_value(2, value.as_str().to_owned())) + .collect(), + 3 => [ + MrpackLoader::Forge, + MrpackLoader::Fabric, + MrpackLoader::Quilt, + MrpackLoader::Modloader, + MrpackLoader::Rift, + MrpackLoader::Liteloader, + MrpackLoader::Minecraft, + MrpackLoader::Bukkit, + MrpackLoader::Spigot, + MrpackLoader::Paper, + MrpackLoader::Purpur, + MrpackLoader::Bungeecord, + MrpackLoader::Waterfall, + MrpackLoader::Velocity, + MrpackLoader::Sponge, + MrpackLoader::Optifine, + MrpackLoader::Iris, + MrpackLoader::Canvas, + MrpackLoader::Vanilla, + MrpackLoader::Datapack, + MrpackLoader::Folia, + MrpackLoader::Neoforge, + MrpackLoader::Babric, + MrpackLoader::BtaBabric, + MrpackLoader::JavaAgent, + MrpackLoader::LegacyFabric, + MrpackLoader::Nilloader, + MrpackLoader::Ornithe, + ] + .into_iter() + .map(|value| compatibility_value(3, value.as_str().to_owned())) + .collect(), + _ => eyre::bail!("unknown compatibility enum ID {}", id.0), + }; + values.sort_by(|a, b| { + b.created + .cmp(&a.created) + .then_with(|| a.value.cmp(&b.value)) + }); + let mut seen = std::collections::HashSet::new(); + eyre::ensure!( + values.iter().all(|value| seen.insert(value.id)), + "compatibility enum value ID collision" + ); + result.insert(id, values); + } + Ok(result) + } + pub async fn list_filter<'a, E>( + id: LoaderFieldEnumId, + filter: HashMap, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list(id, exec, redis) + .await? + .into_iter() + .filter(|value| { + filter.iter().all(|(key, expected)| match key.as_str() { + "type" => value + .ty + .as_deref() + .is_some_and(|ty| expected.as_str() == Some(ty)), + "major" => value + .major + .is_some_and(|major| expected.as_bool() == Some(major)), + _ => false, + }) + }) + .collect()) + } } impl VersionField { - pub async fn insert_many( - items: Vec, - transaction: &mut PgTransaction<'_>, - ) -> Result<()> { - let mut query_version_fields = vec![]; - for item in items { - let base = QueryVersionField { - version_id: item.version_id, - field_id: item.field_id, - int_value: None, - enum_value: None, - string_value: None, - }; - - match item.value { - VersionFieldValue::Integer(i) => { - query_version_fields.push(base.clone().with_int_value(i)) - } - VersionFieldValue::Text(s) => { - query_version_fields.push(base.clone().with_string_value(s)) - } - VersionFieldValue::Boolean(b) => query_version_fields - .push(base.clone().with_int_value(b as i32)), - VersionFieldValue::ArrayInteger(v) => { - for i in v { - query_version_fields - .push(base.clone().with_int_value(i)); - } - } - VersionFieldValue::ArrayText(v) => { - for s in v { - query_version_fields - .push(base.clone().with_string_value(s)); - } - } - VersionFieldValue::ArrayBoolean(v) => { - for b in v { - query_version_fields - .push(base.clone().with_int_value(b as i32)); - } - } - VersionFieldValue::Enum(_, v) => query_version_fields - .push(base.clone().with_enum_value(v.id)), - VersionFieldValue::ArrayEnum(_, v) => { - for ev in v { - query_version_fields - .push(base.clone().with_enum_value(ev.id)); + /// Hydrates game versions before exposing legacy dates to validation or webhooks. + pub async fn from_minecraft_component( + version_id: DBVersionId, + component: &crate::models::exp::minecraft::ModVersion, + ) -> Result> { + let catalog = LoaderFieldEnumValue::minecraft_catalog( + minecraft_metadata().await?, + ) + .await?; + Self::from_minecraft_component_with_catalog( + version_id, component, &catalog, + ) + } + + /// Reuses one catalog across a batch of versions without additional database reads. + pub fn from_minecraft_component_with_catalog( + version_id: DBVersionId, + component: &crate::models::exp::minecraft::ModVersion, + catalog: &[LoaderFieldEnumValue], + ) -> Result> { + let mut fields = vec![ + Self { + version_id, + field_id: LoaderFieldId(1), + field_name: "game_versions".into(), + value: VersionFieldValue::ArrayEnum( + LoaderFieldEnumId(1), + component + .game_versions + .iter() + .map(|name| { + catalog + .iter() + .find(|value| { + value.enum_id == LoaderFieldEnumId(1) + && value.value == name.0 + }) + .cloned() + .ok_or_else(|| { + eyre::eyre!( + "Minecraft version `{name}` is absent from the metadata catalog" + ) + }) + }) + .collect::>>()?, + ), + }, + Self { + version_id, + field_id: LoaderFieldId(3), + field_name: "mrpack_loaders".into(), + value: VersionFieldValue::ArrayEnum( + LoaderFieldEnumId(3), + component + .mrpack_loaders + .iter() + .map(|value| { + compatibility_value( + 3, + value.as_str().to_owned(), + ) + }) + .collect(), + ), + }, + ]; + if let Some(environment) = component.environment { + fields.push(Self { + version_id, + field_id: LoaderFieldId(2), + field_name: "environment".into(), + value: VersionFieldValue::Enum( + LoaderFieldEnumId(2), + compatibility_value( + 2, + environment.as_str().to_owned(), + ), + ), + }); + } + Ok(fields) + } + + pub async fn insert_many( + items: Vec, + transaction: &mut PgTransaction<'_>, + ) -> Result<()> { + let mut patches = HashMap::< + DBVersionId, + serde_json::Map, + >::new(); + for item in items { + let schema = minecraft_fields() + .into_iter() + .find(|field| { + field.field == item.field_name && field.id == item.field_id + }) + .ok_or_else(|| { + eyre::eyre!( + "unknown Minecraft compatibility field `{}`", + item.field_name + ) + })?; + let value = match (&schema.field_type, item.value) { + ( + LoaderFieldType::Enum(id), + VersionFieldValue::Enum(actual, value), + ) if *id == actual => { + let _: crate::models::exp::minecraft::Environment = value.value.parse()?; + serde_json::Value::String(value.value) + }, + ( + LoaderFieldType::ArrayEnum(id), + VersionFieldValue::ArrayEnum(actual, values), + ) if *id == actual => { + if item.field_name == "mrpack_loaders" { + for value in &values { + let _: crate::models::exp::minecraft::MrpackLoader = value.value.parse()?; + } } - } - }; - } - - let (field_ids, version_ids, int_values, enum_values, string_values): ( - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - ) = query_version_fields - .iter() - .map(|l| { - ( - l.field_id.0, - l.version_id.0, - l.int_value, - l.enum_value.as_ref().map_or(-1, |e| e.0), - l.string_value.clone(), - ) - }) - .multiunzip(); - - sqlx::query!( - " - INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value) - SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[]) - ", - &field_ids[..], - &version_ids[..], - &int_values[..] as &[Option], - &string_values[..] as &[Option], - &enum_values[..] as &[i32] - ) - .execute(&mut *transaction) - .await - .wrap_err("inserting version fields")?; - - Ok(()) - } + serde_json::Value::Array(values.into_iter() + .map(|value| serde_json::Value::String(value.value)) + .collect()) + }, + _ => eyre::bail!( + "invalid value type for Minecraft field `{}`", + item.field_name + ), + }; + + patches + .entry(item.version_id) + .or_default() + .insert(item.field_name, value); + } + for (version_id, patch) in patches { + let patch = serde_json::Value::Object(patch); + let result = sqlx::query!( + r#" + UPDATE versions SET components = jsonb_set( + COALESCE(components, '{}'::jsonb), '{minecraft_mod}', + jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb) + || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb) + || $2::jsonb + ) WHERE id = $1 + "#, + version_id.0, + patch + ) + .execute(&mut *transaction) + .await + .wrap_err("updating Minecraft version component")?; + eyre::ensure!( + result.rows_affected() == 1, + "version {} does not exist", + version_id.0 + ); + } + Ok(()) + } pub fn check_parse( version_id: DBVersionId, diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 5aa5eb99a4..be5f88f12b 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -1,7 +1,4 @@ -use super::loader_fields::{ - QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, - VersionField, -}; +use super::loader_fields::{LoaderFieldEnumValue, VersionField, minecraft_metadata}; use super::{DBUser, ids::*}; use crate::database::{PgTransaction, models}; @@ -24,8 +21,8 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -pub const PROJECTS_NAMESPACE: &str = "projects:v5"; -pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v4"; +pub const PROJECTS_NAMESPACE: &str = "projects:v6"; +pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v5"; const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4"; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -648,14 +645,22 @@ impl DBProject { .map(|x| x.to_string().to_lowercase()) .collect::>(); + let minecraft_catalog = LoaderFieldEnumValue::minecraft_catalog( + minecraft_metadata().await.wrap_err("connecting to minecraft metadata")?, + ) + .await + .wrap_err("fetching minecraft version catalog")?; + let all_version_ids = DashSet::new(); + let version_fields: DashMap> = DashMap::new(); let versions: DashMap)>> = sqlx::query!( - " - SELECT DISTINCT mod_id, v.id as id, date_published + r#" + SELECT DISTINCT mod_id, v.id as id, date_published, + v.components AS "components: sqlx::types::Json" FROM mods m INNER JOIN versions v ON m.id = v.mod_id AND v.status = ANY($3) WHERE m.id = ANY($1) OR m.slug = ANY($2) - ", + "#, &project_ids_parsed, &slugs, &*crate::models::projects::VersionStatus::iterator() @@ -664,83 +669,31 @@ impl DBProject { .collect::>() ) .fetch(&mut exec) + .map_err(eyre::Report::from) .try_fold( DashMap::new(), |acc: DashMap)>>, m| { + let result = (|| { let version_id = DBVersionId(m.id); let date_published = m.date_published; all_version_ids.insert(version_id); + if let Some(component) = m.components.0.minecraft_mod.as_ref() { + version_fields.entry(DBProjectId(m.mod_id)).or_default() + .extend(VersionField::from_minecraft_component_with_catalog( + version_id, component, &minecraft_catalog, + ).wrap_err("converting project version minecraft component")?); + } acc.entry(DBProjectId(m.mod_id)) .or_default() .push((version_id, date_published)); - async move { Ok(acc) } + eyre::Ok(acc) + })(); + async move { result } }, ) .await .wrap_err("fetching project versions")?; - let loader_field_enum_value_ids = DashSet::new(); - let version_fields: DashMap> = sqlx::query!( - r#" - SELECT v.mod_id, MIN(vf.version_id) AS "version_id!", vf.field_id, vf.int_value, vf.enum_value, vf.string_value - FROM versions v - INNER JOIN version_fields vf ON v.id = vf.version_id - WHERE v.id = ANY($1) AND vf.version_id = ANY($1) - GROUP BY v.mod_id, vf.field_id, vf.int_value, vf.enum_value, vf.string_value - "#, - &all_version_ids.iter().map(|x| x.0).collect::>() - ) - .fetch(&mut exec) - .try_fold( - DashMap::new(), - |acc: DashMap>, m| { - let qvf = QueryVersionField { - version_id: DBVersionId(m.version_id), - field_id: LoaderFieldId(m.field_id), - int_value: m.int_value, - enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) }, - string_value: m.string_value, - }; - - if m.enum_value != -1 { - loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value)); - } - - acc.entry(DBProjectId(m.mod_id)).or_default().push(qvf); - async move { Ok(acc) } - }, - ) - .await - .wrap_err("fetching project version fields")?; - - let loader_field_enum_values: Vec = sqlx::query!( - r#" - SELECT DISTINCT id, enum_id, value, ordering, created, - metadata->>'type' AS "ty?", - (metadata->>'major')::boolean AS "major?" - FROM loader_field_enum_values lfev - WHERE id = ANY($1) - ORDER BY enum_id, ordering, created DESC - "#, - &loader_field_enum_value_ids - .iter() - .map(|x| x.0) - .collect::>() - ) - .fetch(&mut exec) - .map_ok(|m| QueryLoaderFieldEnumValue { - id: LoaderFieldEnumValueId(m.id), - enum_id: LoaderFieldEnumId(m.enum_id), - value: m.value, - ordering: m.ordering, - created: m.created, - ty: m.ty, - major: m.major, - }) - .try_collect() - .await - .wrap_err("fetching project loader field enum values")?; - let mods_gallery: DashMap> = sqlx::query!( " SELECT DISTINCT mod_id, mg.id gallery_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering @@ -801,17 +754,14 @@ impl DBProject { loaders: Vec, project_types: Vec, games: Vec, - loader_loader_field_ids: Vec, } - let loader_field_ids = DashSet::new(); let loaders_ptypes_games: DashMap = sqlx::query!( " SELECT DISTINCT mod_id, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games, - ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games FROM versions v INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id @@ -819,7 +769,6 @@ impl DBProject { INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id INNER JOIN games g ON lptg.game_id = g.id - LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id WHERE v.id = ANY($1) GROUP BY mod_id ", @@ -828,18 +777,10 @@ impl DBProject { .map_ok(|m| { let project_id = DBProjectId(m.mod_id); - // Add loader fields to the set we need to fetch - let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); - for loader_field_id in &loader_loader_field_ids { - loader_field_ids.insert(*loader_field_id); - } - - // Add loader + loader associated data to the map let version_loader_data = VersionLoaderData { loaders: m.loaders.unwrap_or_default(), project_types: m.project_types.unwrap_or_default(), games: m.games.unwrap_or_default(), - loader_loader_field_ids, }; (project_id, version_loader_data) @@ -850,28 +791,6 @@ impl DBProject { .await .wrap_err("fetching project loader metadata")?; - let loader_fields: Vec = sqlx::query!( - " - SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional - FROM loader_fields lf - WHERE id = ANY($1) - ", - &loader_field_ids.iter().map(|x| x.0).collect::>() - ) - .fetch(&mut exec) - .map_ok(|m| QueryLoaderField { - id: LoaderFieldId(m.id), - field: m.field, - field_type: m.field_type, - enum_type: m.enum_type.map(LoaderFieldEnumId), - min_val: m.min_val, - max_val: m.max_val, - optional: m.optional, - }) - .try_collect() - .await - .wrap_err("fetching project loader fields")?; - let project_rows = sqlx::query!( r#" SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows, @@ -927,7 +846,6 @@ impl DBProject { loaders, mut project_types, games, - loader_loader_field_ids, } = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default(); // Each version is a tuple of (DBVersionId, DateTime) let mut versions = versions.remove(&project_id).map(|x| x.1).unwrap_or_default(); @@ -936,10 +854,6 @@ impl DBProject { let urls = links.remove(&project_id).map(|x| x.1).unwrap_or_default(); let version_fields = version_fields.remove(&project_id).map(|x| x.1).unwrap_or_default(); - let loader_fields = loader_fields.iter() - .filter(|x| loader_loader_field_ids.contains(&x.id)) - .collect::>(); - let components_serial = m.components.0; exp::compat::correct_project_types( &components_serial, @@ -1000,7 +914,7 @@ impl DBProject { gallery }, urls, - aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), + aggregate_version_fields: version_fields, thread_id: DBThreadId(m.thread_id), components, }; diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 49ab222d39..0d0ee63300 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -1,10 +1,7 @@ use super::ids::*; -use super::loader_fields::VersionField; +use super::loader_fields::{LoaderFieldEnumValue, VersionField, minecraft_metadata}; use crate::database::PgTransaction; -use crate::database::models::loader_fields::{ - QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, -}; use crate::file_hosting::FileHost; use crate::models::exp; use xredis::RedisPool; @@ -22,7 +19,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use tracing::error; -pub const VERSIONS_NAMESPACE: &str = "versions:v4"; +pub const VERSIONS_NAMESPACE: &str = "versions:v5"; const VERSION_FILES_NAMESPACE: &str = "versions_files:v4"; pub async fn cleanup_unused_attribution_files_and_groups( @@ -483,17 +480,6 @@ impl DBVersion { .await .wrap_err("unlinking version reports")?; - sqlx::query!( - " - DELETE FROM version_fields vf - WHERE vf.version_id = $1 - ", - id as DBVersionId, - ) - .execute(&mut *transaction) - .await - .wrap_err("deleting version fields")?; - sqlx::query!( " DELETE FROM loaders_versions @@ -662,55 +648,25 @@ impl DBVersion { .await .wrap_err("acquiring database connection")?; - let loader_field_enum_value_ids = DashSet::new(); - let version_fields: DashMap> = sqlx::query!( - " - SELECT version_id, field_id, int_value, enum_value, string_value - FROM version_fields - WHERE version_id = ANY($1) - ", - &version_ids - ) - .fetch(&mut exec) - .map_err(eyre::Report::from) - .try_fold( - DashMap::new(), - |acc: DashMap>, m| { - let qvf = QueryVersionField { - version_id: DBVersionId(m.version_id), - field_id: LoaderFieldId(m.field_id), - int_value: m.int_value, - enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) }, - string_value: m.string_value, - }; - - if m.enum_value != -1 { - loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value)); - } - - acc.entry(DBVersionId(m.version_id)).or_default().push(qvf); - async move { eyre::Ok(acc) } - }, - ) - .await - .wrap_err("fetching version fields")?; + let minecraft_catalog = LoaderFieldEnumValue::minecraft_catalog( + minecraft_metadata().await.wrap_err("connecting to minecraft metadata")?, + ) + .await + .wrap_err("fetching minecraft version catalog")?; #[derive(Default)] struct VersionLoaderData { loaders: Vec, project_types: Vec, games: Vec, - loader_loader_field_ids: Vec, } - let loader_field_ids = DashSet::new(); let loaders_ptypes_games: DashMap = sqlx::query!( " SELECT DISTINCT version_id, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games, - ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games FROM versions v INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id @@ -718,7 +674,6 @@ impl DBVersion { INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id INNER JOIN games g ON lptg.game_id = g.id - LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id WHERE v.id = ANY($1) GROUP BY version_id ", @@ -727,18 +682,10 @@ impl DBVersion { .map_ok(|m| { let version_id = DBVersionId(m.version_id); - // Add loader fields to the set we need to fetch - let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); - for loader_field_id in &loader_loader_field_ids { - loader_field_ids.insert(*loader_field_id); - } - - // Add loader + loader associated data to the map let version_loader_data = VersionLoaderData { loaders: m.loaders.unwrap_or_default(), project_types: m.project_types.unwrap_or_default(), games: m.games.unwrap_or_default(), - loader_loader_field_ids, }; (version_id,version_loader_data) @@ -748,57 +695,6 @@ impl DBVersion { .await .wrap_err("fetching version loader data")?; - // Fetch all loader fields from any version - let loader_fields: Vec = sqlx::query!( - " - SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional - FROM loader_fields lf - WHERE id = ANY($1) - ", - &loader_field_ids.iter().map(|x| x.0).collect::>() - ) - .fetch(&mut exec) - .map_ok(|m| QueryLoaderField { - id: LoaderFieldId(m.id), - field: m.field, - field_type: m.field_type, - enum_type: m.enum_type.map(LoaderFieldEnumId), - min_val: m.min_val, - max_val: m.max_val, - optional: m.optional, - }) - .try_collect() - .await - .wrap_err("fetching loader fields")?; - - let loader_field_enum_values: Vec = sqlx::query!( - r#" - SELECT DISTINCT id, enum_id, value, ordering, created, - metadata->>'type' AS "ty?", - (metadata->>'major')::boolean AS "major?" - FROM loader_field_enum_values lfev - WHERE id = ANY($1) - ORDER BY enum_id, ordering, created ASC - "#, - &loader_field_enum_value_ids - .iter() - .map(|x| x.0) - .collect::>() - ) - .fetch(&mut exec) - .map_ok(|m| QueryLoaderFieldEnumValue { - id: LoaderFieldEnumValueId(m.id), - enum_id: LoaderFieldEnumId(m.enum_id), - value: m.value, - ordering: m.ordering, - created: m.created, - ty: m.ty, - major: m.major, - }) - .try_collect() - .await - .wrap_err("fetching loader field enum values")?; - #[derive(Deserialize)] struct Hash { pub file_id: DBFileId, @@ -930,16 +826,15 @@ impl DBVersion { .fetch(&mut exec) .map_err(eyre::Report::from) .try_fold(DashMap::new(), |acc, v| { + let result = (|| { let version_id = DBVersionId(v.id); let VersionLoaderData { loaders, project_types, games, - loader_loader_field_ids, } = loaders_ptypes_games.remove(&version_id).map(|x|x.1).unwrap_or_default(); let files = files.remove(&version_id).map(|x|x.1).unwrap_or_default(); let hashes = hashes.remove(&version_id).map(|x|x.1).unwrap_or_default(); - let version_fields = version_fields.remove(&version_id).map(|x|x.1).unwrap_or_default(); let dependencies = dependencies.remove(&version_id).map(|x|x.1).unwrap_or_default(); let dependencies = dependencies .into_iter() @@ -955,11 +850,14 @@ impl DBVersion { }) .collect_vec(); - let loader_fields = loader_fields.iter() - .filter(|x| loader_loader_field_ids.contains(&x.id)) - .collect::>(); - let components_serial = v.components.0; + let version_fields = components_serial.minecraft_mod.as_ref() + .map(|component| VersionField::from_minecraft_component_with_catalog( + version_id, component, &minecraft_catalog, + )) + .transpose() + .wrap_err("converting version minecraft component")? + .unwrap_or_default(); let query_version = VersionQueryResult { inner: DBVersion { id: DBVersionId(v.id), @@ -1014,7 +912,7 @@ impl DBVersion { files }, - version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, false), + version_fields, loaders, project_types, games, @@ -1024,7 +922,9 @@ impl DBVersion { }; acc.insert(v.id, query_version); - async move { eyre::Ok(acc) } + eyre::Ok(acc) + })(); + async move { result } }) .await .wrap_err("fetching versions")?; diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 3cd396d9aa..069a0da638 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -130,6 +130,8 @@ async fn app() -> std::io::Result<()> { .await .expect("Minecraft metadata database connection failed"), ); + database::models::loader_fields::register_minecraft_metadata(minecraft_meta_db.clone()) + .expect("Minecraft metadata service registration failed"); // Redis connector let redis_pool = redis::from_env("").await; diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 7a0929e943..2f99a30d37 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -113,76 +113,109 @@ pub enum Language { /// Supported Minecraft version environments, including the explicit legacy unknown value. #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - utoipa::ToSchema, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + utoipa::ToSchema, + strum::IntoStaticStr, + strum::EnumString, )] #[serde(rename_all = "snake_case")] +#[strum( + serialize_all = "snake_case", + parse_err_ty = eyre::Report, + parse_err_fn = invalid_minecraft_metadata_value +)] pub enum Environment { - ClientAndServer, - ClientOnly, - ClientOnlyServerOptional, - SingleplayerOnly, - ServerOnly, - ServerOnlyClientOptional, - DedicatedServerOnly, - ClientOrServer, - ClientOrServerPrefersBoth, - Unknown, + ClientAndServer, + ClientOnly, + ClientOnlyServerOptional, + SingleplayerOnly, + ServerOnly, + ServerOnlyClientOptional, + DedicatedServerOnly, + ClientOrServer, + ClientOrServerPrefersBoth, + Unknown, } /// Loader field values for mrpack versions, not mrpack manifest dependency keys. /// The legacy field was populated from every non-mrpack loader. #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - utoipa::ToSchema, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + utoipa::ToSchema, + strum::IntoStaticStr, + strum::EnumString, )] #[serde(rename_all = "lowercase")] +#[strum( + serialize_all = "lowercase", + parse_err_ty = eyre::Report, + parse_err_fn = invalid_minecraft_metadata_value +)] pub enum MrpackLoader { - Forge, - Fabric, - Quilt, - Modloader, - Rift, - Liteloader, - Minecraft, - Bukkit, - Spigot, - Paper, - Purpur, - Bungeecord, - Waterfall, - Velocity, - Sponge, - Optifine, - Iris, - Canvas, - Vanilla, - Datapack, - Folia, - Neoforge, - Babric, - #[serde(rename = "bta-babric")] - BtaBabric, - #[serde(rename = "java-agent")] - JavaAgent, - #[serde(rename = "legacy-fabric")] - LegacyFabric, - Nilloader, - Ornithe, + Forge, + Fabric, + Quilt, + Modloader, + Rift, + Liteloader, + Minecraft, + Bukkit, + Spigot, + Paper, + Purpur, + Bungeecord, + Waterfall, + Velocity, + Sponge, + Optifine, + Iris, + Canvas, + Vanilla, + Datapack, + Folia, + Neoforge, + Babric, + #[serde(rename = "bta-babric")] + #[strum(serialize = "bta-babric")] + BtaBabric, + #[serde(rename = "java-agent")] + #[strum(serialize = "java-agent")] + JavaAgent, + #[serde(rename = "legacy-fabric")] + #[strum(serialize = "legacy-fabric")] + LegacyFabric, + Nilloader, + Ornithe, +} + +impl Environment { + pub fn as_str(&self) -> &'static str { + self.into() + } +} + +impl MrpackLoader { + pub fn as_str(&self) -> &'static str { + self.into() + } +} + +fn invalid_minecraft_metadata_value(value: &str) -> eyre::Report { + eyre::eyre!("invalid Minecraft metadata value `{value}`") } component::define! { diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs index d603a28c7e..80e81323ab 100644 --- a/apps/labrinth/src/models/v2/projects.rs +++ b/apps/labrinth/src/models/v2/projects.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use super::super::ids::OrganizationId; use crate::database::models::{DBProjectDisclosure, DBProjectId, version_item}; use crate::models::disclosures::ProjectDisclosureType; +use crate::models::exp::minecraft::Environment; use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId}; use crate::models::link_platform::LinkPlatform; use crate::models::projects::{ @@ -141,6 +142,13 @@ impl LegacyProject { .collect(); if let Some(versions_item) = versions_item { + if let Some(component) = versions_item.inner.components.minecraft_mod.as_ref() { + (client_side, server_side) = + v2_reroute::convert_minecraft_environment_to_v2_side_types( + component.environment.unwrap_or(Environment::Unknown), + Some(&og_project_type), + ); + } else { // Extract side types from remaining fields let fields = versions_item .version_fields @@ -154,6 +162,7 @@ impl LegacyProject { &fields, Some(&*og_project_type), ); + } // - if loader is mrpack, this is a modpack // the loaders are whatever the corresponding loader fields are @@ -367,18 +376,29 @@ impl From for LegacyVersion { } } } - let environment = data - .fields - .get("environment") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - .to_string(); + let minecraft = data.components.minecraft_mod.as_ref(); + if let Some(component) = minecraft { + game_versions = component.game_versions.iter().map(ToString::to_string).collect(); + } + let environment = if let Some(component) = minecraft { + component.environment.unwrap_or(Environment::Unknown).as_str().to_string() + } else { + data.fields + .get("environment") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string() + }; // - if loader is mrpack, this is a modpack // the v2 loaders are whatever the corresponding loader fields are let mut loaders = data.loaders.into_iter().map(|l| l.0).collect::>(); - if loaders.contains(&"mrpack".to_string()) + if loaders.contains(&"mrpack".to_string()) && let Some(component) = minecraft { + loaders = component.mrpack_loaders.iter() + .map(|loader| loader.as_str().to_string()) + .collect(); + } else if loaders.contains(&"mrpack".to_string()) && let Some((_, mrpack_loaders)) = data .fields .into_iter() diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 95baf05061..7045f1acf9 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -17,7 +17,6 @@ use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, post}; use serde::{Deserialize, Serialize}; -use serde_json::json; use xredis::RedisPool; use std::collections::HashMap; @@ -217,32 +216,14 @@ pub async fn project_create( .initial_versions .into_iter() .map(|v| { - let mut fields = HashMap::new(); - fields.extend( - v2_reroute::convert_v2_side_types_to_v3_side_types( - client_side, - server_side, - ), - ); - if let Some(environment) = v.environment { - fields.insert( - "environment".to_string(), - json!(environment), - ); - } - fields.insert( - "game_versions".to_string(), - json!(v.game_versions), - ); - - // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. - // Setting of 'project_type' directly is removed, it's loader-based now. - if project_type == "modpack" { - fields.insert( - "mrpack_loaders".to_string(), - json!(v.loaders), - ); - } + let component = + crate::models::exp::minecraft::ModVersion { + game_versions: v.game_versions, + environment: Some(v.environment.unwrap_or_else(|| super::version_creation::environment_from_sides(client_side, server_side))), + mrpack_loaders: if project_type == "modpack" { + super::version_creation::parse_mrpack_loaders(&v.loaders)? + } else { Vec::new() }, + }; let loaders = if project_type == "modpack" { vec![Loader("mrpack".to_string())] @@ -250,7 +231,7 @@ pub async fn project_create( v.loaders }; - v3::version_creation::InitialVersionData { + Ok::<_, CreateError>(v3::version_creation::InitialVersionData { project_id: v.project_id, file_parts: v.file_parts, version_number: v.version_number, @@ -265,10 +246,11 @@ pub async fn project_create( file_types: v.file_types, uploaded_images: v.uploaded_images, ordering: v.ordering, - fields, - } + minecraft_mod: Some(component), + fields: HashMap::new(), + }) }) - .collect(); + .collect::, CreateError>>()?; let mut link_urls = HashMap::new(); if let Some(issue_url) = legacy_create.issues_url { diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 3f09f2594d..1a6e036fef 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -6,7 +6,7 @@ use crate::models::disclosures::ProjectDisclosureType; use crate::models::link_platform::LinkPlatform; use crate::models::pats::Scopes; use crate::models::projects::{ - Link, MonetizationStatus, Project, ProjectStatus, Version, + Link, MonetizationStatus, Project, ProjectStatus, }; use crate::models::v2::projects::{ DonationLink, LegacyProject, LegacySideType, LegacyVersion, @@ -785,26 +785,36 @@ pub async fn project_edit( .await .wrap_internal_err("fetching versions from database")?; for version in versions { - let version = Version::from(version); - let mut fields = version.fields; + let environment = version + .inner + .components + .minecraft_mod + .as_ref() + .and_then(|component| component.environment); let (current_client_side, current_server_side) = - v2_reroute::convert_v3_side_types_to_v2_side_types( - &fields, None, + v2_reroute::convert_minecraft_environment_to_v2_side_types( + environment.unwrap_or( + crate::models::exp::minecraft::Environment::Unknown, + ), + 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_v2_side_types_to_v3_side_types( - client_side, - server_side, - )); + let environment = super::version_creation::environment_from_sides( + client_side.unwrap_or(current_client_side), + server_side.unwrap_or(current_server_side), + ); + let component = crate::models::exp::minecraft::ModVersionEdit { + game_versions: None, + environment: Some(Some(environment)), + mrpack_loaders: None, + }; response = v3::versions::version_edit_helper( req.clone(), - (version.id,), + (version.inner.id.into(),), pool.clone(), redis.clone(), v3::versions::EditVersion { - fields, + minecraft_mod: Some(component), ..Default::default() }, session_queue.clone(), diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 97926f904f..855f9961f6 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -1,16 +1,20 @@ -use crate::util::error::ApiContext as _; -use std::collections::HashMap; +use crate::util::error::{ApiContext as _, Context as _}; use super::ApiError; -use crate::database::PgPool; -use crate::database::models::loader_fields::LoaderFieldEnumValue; +use crate::database::models::legacy_loader_fields::{ + legacy_game_version_date, legacy_game_version_name, +}; +use crate::database::{MinecraftMetaDb, PgPool}; use crate::models::v2::projects::LegacySideType; use crate::routes::v2_reroute::capitalize_first; -use crate::routes::v3::tags::{LinkPlatformQueryData, LoaderFieldsEnumQuery}; +use crate::routes::v3::tags::LinkPlatformQueryData; use crate::routes::{v2_reroute, v3}; use actix_web::{HttpResponse, get, web}; use chrono::{DateTime, Utc}; use itertools::Itertools; +use minecraft_meta_model::{ + model::MinecraftVersion, upstream::mojang::VersionType, +}; use xredis::RedisPool; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -184,48 +188,51 @@ pub struct GameVersionQuery { )] #[get("/game_version")] pub async fn game_version_list( - pool: web::Data, - query: web::Query, - redis: web::Data, -) -> Result { - let mut filters = HashMap::new(); - if let Some(type_) = &query.type_ { - filters.insert("type".to_string(), serde_json::json!(type_)); - } - if let Some(major) = query.major { - filters.insert("major".to_string(), serde_json::json!(major)); - } - let response = v3::tags::loader_fields_list( - pool, - web::Query(LoaderFieldsEnumQuery { - loader_field: "game_versions".to_string(), - filters: Some(filters), - }), - redis, - ) - .await - .wrap_api_err("fetching game versions")?; - - // Convert to V2 format - Ok( - match v2_reroute::extract_ok_json::>(response) - .await - { - Ok(fields) => { - let fields = fields - .into_iter() - .map(|f| GameVersionQueryData { - version: f.value, - version_type: f.ty.unwrap_or_default(), - date: f.created, - major: f.major.unwrap_or_default(), - }) - .collect::>(); - HttpResponse::Ok().json(fields) - } - Err(response) => response, - }, - ) + minecraft_meta_db: web::Data, + query: web::Query, +) -> Result>, ApiError> { + let mut conn = minecraft_meta_db + .0 + .connection() + .await + .wrap_internal_err("acquiring minecraft-meta connection")?; + let versions = MinecraftVersion::all() + .exec(&mut conn) + .await + .wrap_internal_err("fetching minecraft-meta game versions")?; + let mut versions = versions + .into_iter() + .map(|row| { + let entry = row.catalog_entry.0; + let version_type = match entry.ty { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldAlpha => "alpha", + VersionType::OldBeta => "beta", + }; + let date = DateTime::::from_timestamp( + entry.release_time.as_second(), + entry.release_time.subsec_nanosecond() as u32, + ) + .ok_or_else(|| ApiError::Internal(eyre::eyre!( + "minecraft-meta release timestamp is out of range" + )))?; + Ok(GameVersionQueryData { + version: legacy_game_version_name(&row.name.to_string()).to_owned(), + version_type: version_type.to_owned(), + date: legacy_game_version_date(&row.name.to_string(), date), + major: matches!(entry.ty, VersionType::Release), + }) + }) + .collect::, ApiError>>()?; + versions.retain(|version| { + query.type_.as_ref().is_none_or(|ty| ty == &version.version_type) + && query.major.is_none_or(|major| major == version.major) + }); + versions.sort_unstable_by(|a, b| { + b.date.cmp(&a.date).then_with(|| a.version.cmp(&b.version)) + }); + Ok(web::Json(versions)) } #[derive(serde::Serialize, utoipa::ToSchema)] diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 247037373a..8196c3b916 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -1,11 +1,12 @@ use crate::database::PgPool; -use crate::database::models::loader_fields::VersionField; use crate::database::models::{project_item, version_item}; use crate::file_hosting::FileHost; +use crate::models::exp::minecraft::{Environment, ModVersion, MrpackLoader}; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::projects::{ Dependency, FileType, Loader, Version, VersionStatus, VersionType, }; +use crate::models::v2::projects::LegacySideType; use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; @@ -17,8 +18,8 @@ use actix_multipart::Multipart; use actix_web::http::header::ContentDisposition; use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, post, web}; +use minecraft_meta_model::model::MinecraftVersionName; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::collections::HashMap; use validator::Validate; use xredis::RedisPool; @@ -53,8 +54,8 @@ pub struct InitialVersionData { )] pub dependencies: Vec, #[validate(length(min = 1))] - pub game_versions: Vec, - pub environment: Option, + pub game_versions: Vec, + pub environment: Option, #[serde(alias = "version_type")] pub release_channel: VersionType, #[validate(length(min = 1))] @@ -74,6 +75,28 @@ pub struct InitialVersionData { pub ordering: Option, } +pub(super) fn parse_mrpack_loaders( + loaders: &[Loader], +) -> Result, eyre::Report> { + loaders.iter().map(|loader| loader.0.parse()).collect() +} + +pub(super) fn environment_from_sides( + client: LegacySideType, + server: LegacySideType, +) -> Environment { + use LegacySideType::{Optional, Required, Unsupported}; + match (client, server) { + (Required, Required) => Environment::ClientAndServer, + (Required, Unsupported) => Environment::ClientOnly, + (Required, Optional) => Environment::ClientOnlyServerOptional, + (Unsupported, Required) => Environment::ServerOnly, + (Optional, Required) => Environment::ServerOnlyClientOptional, + (Optional, Optional) => Environment::ClientOrServer, + _ => Environment::Unknown, + } +} + // under `/api/v1/version` /// Create a version on an existing project. #[utoipa::path( @@ -148,87 +171,23 @@ pub async fn version_create( } } - // Convert input data to V3 format - let mut fields = HashMap::new(); - fields.insert( - "game_versions".to_string(), - 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 side types - let loaders = - match v3::tags::loader_list(client.clone(), redis.clone()) - .await - { - Ok(loader_response) => { - (v2_reroute::extract_ok_json::< - Vec, - >(loader_response) - .await) - .unwrap_or_default() - } - Err(_) => vec![], - }; - - let loader_fields_aggregate = loaders - .into_iter() - .filter_map(|loader| { - if legacy_create - .loaders - .contains(&Loader(loader.name.clone())) - { - Some(loader.supported_fields) - } else { - None - } - }) - .flatten() - .collect::>(); - - // Copies side types of another version of the project. - // 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 that field explicitly set. - - // Check if loader_fields_aggregate contains the side types - // We assume these four fields are linked together. - if loader_fields_aggregate - .iter() - .any(|field| field == "environment") - { - let environment = - if let Some(environment) = legacy_create.environment { - json!(environment) - } else { - // If so, we get the field of an example version of the project, and set the side types to match. - get_example_version_fields( - legacy_create.project_id, - client, - &redis, - ) - .await? - .into_iter() - .flatten() - .find(|f| f.field_name == "environment") - .map_or(json!("unknown"), |f| { - f.value.serialize_internal() - }) - }; - - fields.insert("environment".into(), environment); - } + let environment = match legacy_create.environment { + Some(environment) => environment, + None => get_example_environment( + legacy_create.project_id, + client, + &redis, + ) + .await? + .unwrap_or(Environment::Unknown), + }; // Handle project type via file extension prediction let mut project_type = None; for file_part in &legacy_create.file_parts { - if let Some(ext) = file_part.split('.').next_back() { - match ext { - "mrpack" | "mrpack-primary" => { - project_type = Some("modpack"); - break; - } - // No other type matters - _ => {} - } + if let Some("mrpack" | "mrpack-primary") = + file_part.split('.').next_back() + { + project_type = Some("modpack"); break; } } @@ -253,14 +212,15 @@ pub async fn version_create( } } - // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. - // Setting of 'project_type' directly is removed, it's loader-based now. - if project_type == Some("modpack") { - fields.insert( - "mrpack_loaders".to_string(), - json!(legacy_create.loaders), - ); - } + let component = ModVersion { + game_versions: legacy_create.game_versions, + environment: Some(environment), + mrpack_loaders: if project_type == Some("modpack") { + parse_mrpack_loaders(&legacy_create.loaders)? + } else { + Vec::new() + }, + }; let loaders = if project_type == Some("modpack") { vec![Loader("mrpack".to_string())] @@ -283,7 +243,8 @@ pub async fn version_create( file_types: legacy_create.file_types, uploaded_images: legacy_create.uploaded_images, ordering: legacy_create.ordering, - fields, + minecraft_mod: Some(component), + fields: HashMap::new(), }) } }, @@ -313,12 +274,11 @@ pub async fn version_create( } } -// Gets version fields of an example version of a project, if one exists. -async fn get_example_version_fields( +async fn get_example_environment( project_id: Option, pool: Data, redis: &RedisPool, -) -> Result>, CreateError> { +) -> Result, CreateError> { let Some(project_id) = project_id else { return Ok(None); }; @@ -336,7 +296,11 @@ async fn get_example_version_fields( else { return Ok(None); }; - Ok(Some(example_version.version_fields)) + Ok(example_version + .inner + .components + .minecraft_mod + .and_then(|component| component.environment)) } // under /api/v1/version/{version_id} diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 2e2c410fd2..4ef98de228 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -299,8 +299,17 @@ pub async fn versions_get( .wrap_request_err("deserializing `featured`")?, version_type: query .version_type - .map(|value| { - serde_json::from_value(serde_json::Value::String(value)) + .map(|value| match value.as_str() { + "release" => { + Ok(crate::models::projects::VersionType::Release) + } + "beta" => { + Ok(crate::models::projects::VersionType::Beta) + } + "alpha" => { + Ok(crate::models::projects::VersionType::Alpha) + } + _ => Err(eyre::eyre!("invalid version type `{value}`")), }) .transpose() .wrap_request_err("deserializing `version_type`")?, @@ -567,7 +576,9 @@ pub struct EditVersion { custom(function = "crate::util::validate::validate_deps") )] pub dependencies: Option>, - pub game_versions: Option>, + #[schema(value_type = Option>)] + pub game_versions: + Option>, pub loaders: Option>, pub featured: Option, pub downloads: Option, @@ -640,14 +651,6 @@ pub async fn version_edit( } } - let mut fields = HashMap::new(); - if new_version.game_versions.is_some() { - fields.insert( - "game_versions".to_string(), - serde_json::json!(new_version.game_versions), - ); - } - // Get the older version to get info from let old_version = match v3::versions::version_get_helper( req.clone(), @@ -674,18 +677,22 @@ pub async fn version_edit( Err(response) => return Ok(response), }; - // If this has 'mrpack_loaders' as a loader field previously, this is a modpack. - // Therefore, if we are modifying the 'loader' field in this case, - // we are actually modifying the 'mrpack_loaders' loader field - let mut loaders = new_version.loaders.clone(); - if old_version.fields.contains_key("mrpack_loaders") - && new_version.loaders.is_some() + let mut component = crate::models::exp::minecraft::ModVersionEdit { + game_versions: new_version.game_versions, + environment: None, + mrpack_loaders: None, + }; + let mut loaders = new_version.loaders; + if old_version + .loaders + .iter() + .any(|loader| loader.0 == "mrpack") + && let Some(values) = loaders.take() { - fields.insert( - "mrpack_loaders".to_string(), - serde_json::json!(new_version.loaders), + component.mrpack_loaders = Some( + super::version_creation::parse_mrpack_loaders(&values) + .wrap_request_err("parsing mrpack loaders")?, ); - loaders = None; } let new_version = v3::versions::EditVersion { @@ -708,18 +715,16 @@ pub async fn version_edit( .collect::>() }), ordering: None, - fields, + minecraft_mod: Some(component), + fields: HashMap::new(), }; - let response = v3::versions::version_edit( + let response = v3::versions::version_edit_helper( req, - info, + info.into_inner(), pool, redis, - web::Json( - serde_json::to_value(new_version) - .wrap_request_err("serializing version edit")?, - ), + new_version, session_queue, search_state, ) diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index 5576c0f2fa..cc9fd23b72 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use super::ApiError; use super::v3::project_creation::CreateError; +use crate::models::exp::minecraft::Environment; use crate::models::v2::projects::LegacySideType; use crate::util::actix::{ MultipartSegment, MultipartSegmentData, generate_multipart, @@ -206,7 +207,18 @@ pub fn convert_v3_environment_to_v2_side_types( environment: &str, project_type: Option<&str>, ) -> (LegacySideType, LegacySideType) { - use LegacySideType::{Optional, Required, Unknown, Unsupported}; + convert_minecraft_environment_to_v2_side_types( + environment.parse().unwrap_or(Environment::Unknown), + project_type, + ) +} + +/// Converts typed Minecraft metadata without going through legacy loader fields. +pub fn convert_minecraft_environment_to_v2_side_types( + environment: Environment, + project_type: Option<&str>, +) -> (LegacySideType, LegacySideType) { + use LegacySideType::{Optional, Required, Unknown, Unsupported}; match project_type { Some("plugin") => (Unsupported, Required), @@ -214,16 +226,13 @@ pub fn convert_v3_environment_to_v2_side_types( Some("shader") => (Required, Unsupported), Some("resourcepack") => (Required, Unsupported), _ => 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" + Environment::ClientAndServer | Environment::SingleplayerOnly => (Required, Required), + Environment::ClientOnly => (Required, Unsupported), + Environment::ClientOnlyServerOptional => (Required, Optional), + Environment::ServerOnly | Environment::DedicatedServerOnly => (Unsupported, Required), + Environment::ServerOnlyClientOptional => (Optional, Required), + Environment::ClientOrServer | Environment::ClientOrServerPrefersBoth => (Optional, Optional), + Environment::Unknown => (Unknown, Unknown), }, } } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 3eaacab9ab..b595de873a 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -977,7 +977,7 @@ async fn project_create_inner( }) } - let project_builder_actual = models::project_item::ProjectBuilder { + let mut project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), team_id, organization_id: project_create_data @@ -1016,6 +1016,11 @@ async fn project_create_inner( components: exp::ProjectSerial::default(), }; let project_builder = project_builder_actual.clone(); + for version in &mut project_builder_actual.initial_versions { + if version.components.minecraft_mod.is_some() { + version.version_fields.clear(); + } + } let now = Utc::now(); @@ -1193,6 +1198,7 @@ async fn create_initial_version( let version_fields = try_create_version_fields( version_id, &version_data.fields, + version_data.minecraft_mod.as_ref(), &loader_fields, &mut loader_field_enum_values, )?; @@ -1224,7 +1230,10 @@ async fn create_initial_version( version_type: version_data.release_channel.to_string(), requested_status: None, ordering: version_data.ordering, - components: exp::VersionSerial::default(), + components: exp::VersionSerial { + minecraft_mod: version_data.minecraft_mod.clone(), + ..Default::default() + }, }; Ok(version) diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 4bcff5f6bc..f61002dae9 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -91,6 +91,9 @@ pub struct InitialVersionData { // The ordering relative to other versions pub ordering: Option, + #[serde(default)] + pub minecraft_mod: Option, + // Flattened loader fields // All other fields are loader-specific VersionFields // These are flattened during serialization @@ -383,6 +386,7 @@ async fn version_create_inner( let version_fields = try_create_version_fields( version_id, &version_create_data.fields, + version_create_data.minecraft_mod.as_ref(), &loader_fields, &mut loader_field_enum_values, )?; @@ -414,7 +418,10 @@ async fn version_create_inner( status: version_create_data.status, requested_status: None, ordering: version_create_data.ordering, - components: exp::VersionSerial::default(), + components: exp::VersionSerial { + minecraft_mod: version_create_data.minecraft_mod.clone(), + ..Default::default() + }, }); return Ok(()); @@ -474,7 +481,7 @@ async fn version_create_inner( let version_data = initial_version_data.ok_or_else(|| { CreateError::InvalidInput("`data` field is required".to_string()) })?; - let builder = version_builder.ok_or_else(|| { + let mut builder = version_builder.ok_or_else(|| { CreateError::InvalidInput("`data` field is required".to_string()) })?; @@ -564,11 +571,17 @@ async fn version_create_inner( dependencies: version_data.dependencies, loaders: version_data.loaders, fields: version_data.fields, - components: exp::VersionQuery::default(), + components: exp::VersionQuery { + minecraft_mod: builder.components.minecraft_mod.clone(), + ..Default::default() + }, files_missing_attribution: Vec::new(), }; let project_id = builder.project_id; + if builder.components.minecraft_mod.is_some() { + builder.version_fields.clear(); + } builder .insert(transaction, redis, file_host, delphi_scan_files) .await?; @@ -1196,16 +1209,65 @@ pub fn get_name_and_extension( pub fn try_create_version_fields( version_id: VersionId, submitted_fields: &HashMap, + minecraft_mod: Option<&crate::models::exp::minecraft::ModVersion>, loader_fields: &[LoaderField], loader_field_enum_values: &mut HashMap< models::LoaderFieldId, Vec, >, ) -> Result, CreateError> { - let mut version_fields = vec![]; + let typed_names = ["game_versions", "environment", "mrpack_loaders"]; + if minecraft_mod.is_some() + && submitted_fields + .keys() + .any(|key| typed_names.contains(&key.as_str())) + { + return Err(CreateError::InvalidInput( + "cannot combine typed and flattened Minecraft fields".into(), + )); + } + let mut version_fields = if let Some(component) = minecraft_mod { + for (name, count) in [ + ("game_versions", component.game_versions.len()), + ("mrpack_loaders", component.mrpack_loaders.len()), + ] { + if let Some(schema) = + loader_fields.iter().find(|field| field.field == name) + && (schema.min_val.is_some_and(|min| (count as i64) < i64::from(min)) + || schema.max_val.is_some_and(|max| (count as i64) > i64::from(max))) + { + return Err(CreateError::InvalidInput(format!( + "invalid number of values for `{name}`" + ))); + } + } + let catalog = loader_field_enum_values + .values() + .flatten() + .cloned() + .collect_vec(); + VersionField::from_minecraft_component_with_catalog( + version_id.into(), + component, + &catalog, + ) + .map_err(|error| CreateError::InvalidInput(error.to_string()))? + .into_iter() + .filter(|field| { + loader_fields + .iter() + .any(|schema| schema.field == field.field_name) + }) + .collect() + } else { + Vec::new() + }; let mut remaining_mandatory_loader_fields = loader_fields .iter() - .filter(|lf| !lf.optional) + .filter(|lf| { + !(lf.optional + || minecraft_mod.is_some() && typed_names.contains(&lf.field.as_str())) + }) .map(|lf| lf.field.clone()) .collect::>(); for (key, value) in submitted_fields { diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 5718164ff1..99800e02ee 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -598,7 +598,6 @@ async fn update_files_internal( .await .wrap_internal_err("forcing generic plan for update query")?; - // TODO: de-hardcode this and actually use version fields system let latest_version_ids = sqlx::query_scalar!( r#" SELECT latest.id AS "id!" @@ -622,10 +621,9 @@ async fn update_files_internal( WHERE v.mod_id = m.id AND (CARDINALITY($4::VARCHAR[]) = 0 OR v.version_type = ANY($4)) AND EXISTS ( - SELECT 1 FROM version_fields vf - INNER JOIN loader_field_enum_values lfev ON lfev.id = vf.enum_value - WHERE vf.version_id = v.id AND vf.field_id = 3 - AND (CARDINALITY($2::VARCHAR[]) = 0 OR lfev.value = ANY($2)) + SELECT 1 + FROM JSONB_ARRAY_ELEMENTS_TEXT(v.components->'minecraft_mod'->'game_versions') AS gv(value) + WHERE CARDINALITY($2::VARCHAR[]) = 0 OR gv.value = ANY($2) ) AND EXISTS ( SELECT 1 FROM loaders_versions lv @@ -861,10 +859,9 @@ struct LatestVersionFilter<'a> { /// its loaders, version types and loader fields, keyed by the filter's hash. /// /// Versions are ordered like `impl Ord for DBVersion`, and visibility is left -/// to the caller. A loader field only rules out versions that have it, using -/// the same rules as `VersionField::from_query_json`: the field must be linked -/// to one of the version's loaders, and a non-array field must have exactly one -/// value. +/// to the caller. Unknown fields, missing Minecraft components, and absent or +/// JSON-null environments are ignored. Present array fields require an overlapping +/// string value; empty arrays do not match any requested values. async fn get_latest_matching_versions( filters: &[LatestVersionFilter<'_>], pool: &PgPool, @@ -900,36 +897,23 @@ async fn get_latest_matching_versions( AND NOT EXISTS ( SELECT 1 FROM JSONB_EACH(q.loader_fields) AS req(field, vals) - INNER JOIN loader_fields lf ON lf.field = req.field - WHERE EXISTS ( - SELECT 1 FROM loaders_versions lv - INNER JOIN loader_fields_loaders lfl ON lfl.loader_id = lv.loader_id - WHERE lv.version_id = v.id AND lfl.loader_field_id = lf.id - ) - AND ( - lf.field_type IN ('array_integer', 'array_text', 'array_boolean', 'array_enum') - OR ( - SELECT COUNT(*) FROM version_fields vf - WHERE vf.version_id = v.id AND vf.field_id = lf.id - ) = 1 - ) - AND NOT EXISTS ( - SELECT 1 - FROM version_fields vf - LEFT JOIN loader_field_enum_values lfev ON lfev.id = vf.enum_value - CROSS JOIN JSONB_ARRAY_ELEMENTS(req.vals) AS want(val) - WHERE vf.version_id = v.id AND vf.field_id = lf.id - AND CASE - WHEN lf.field_type IN ('enum', 'array_enum') - THEN JSONB_TYPEOF(want.val) = 'string' AND lfev.value = want.val #>> '{}' - WHEN lf.field_type IN ('text', 'array_text') - THEN JSONB_TYPEOF(want.val) = 'string' AND vf.string_value = want.val #>> '{}' - WHEN lf.field_type IN ('integer', 'array_integer') - THEN JSONB_TYPEOF(want.val) = 'number' AND vf.int_value = (want.val #>> '{}')::NUMERIC - WHEN lf.field_type IN ('boolean', 'array_boolean') - THEN JSONB_TYPEOF(want.val) = 'boolean' AND (vf.int_value <> 0) = (want.val #>> '{}')::BOOLEAN - END - ) + WHERE req.field IN ('game_versions', 'mrpack_loaders', 'environment') + AND v.components->'minecraft_mod' IS NOT NULL + AND v.components->'minecraft_mod' <> 'null'::JSONB + AND ( + req.field <> 'environment' + OR COALESCE(JSONB_TYPEOF(v.components->'minecraft_mod'->'environment') = 'string', FALSE) + ) + AND NOT EXISTS ( + SELECT 1 + FROM JSONB_ARRAY_ELEMENTS(req.vals) AS want(val) + WHERE JSONB_TYPEOF(want.val) = 'string' + AND CASE WHEN req.field = 'environment' THEN + v.components->'minecraft_mod'->'environment' = want.val + ELSE + COALESCE(v.components->'minecraft_mod'->req.field, '[]'::JSONB) @> JSONB_BUILD_ARRAY(want.val) + END + ) ) ORDER BY v.ordering DESC NULLS FIRST, v.date_published DESC, v.id DESC LIMIT 1 diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 0c66d67b84..8dba21adc4 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -395,6 +395,9 @@ pub struct EditVersion { )] pub ordering: Option>, + #[serde(default)] + pub minecraft_mod: Option, + // Flattened loader fields // All other fields are loader-specific VersionFields // These are flattened during serialization @@ -414,6 +417,7 @@ pub struct EditVersionFileType { #[utoipa::path( tag = "versions", patch, + request_body = serde_json::Value, params( ("id" = VersionId, Path, description = "The ID of the version") ), @@ -436,7 +440,7 @@ pub async fn version_edit_route( info: web::Path<(VersionId,)>, pool: web::Data, redis: web::Data, - new_version: web::Json, + new_version: web::Json, session_queue: web::Data, search_state: web::Data, ) -> Result { @@ -457,13 +461,11 @@ pub async fn version_edit( info: web::Path<(VersionId,)>, pool: web::Data, redis: web::Data, - new_version: web::Json, + new_version: web::Json, session_queue: web::Data, search_state: web::Data, ) -> Result { - let new_version: EditVersion = - serde_json::from_value(new_version.into_inner()) - .wrap_request_err("deserializing JSON data")?; + let new_version = new_version.into_inner(); version_edit_helper( req, info.into_inner(), @@ -710,6 +712,55 @@ pub(crate) async fn version_edit_in_transaction( )?; } + if let Some(edit) = new_version.minecraft_mod { + use crate::models::exp::component::ComponentEdit; + + if new_version.fields.keys().any(|key| { + matches!( + key.as_str(), + "game_versions" | "environment" | "mrpack_loaders" + ) + }) { + return Err(ApiError::Request(eyre::eyre!( + "cannot combine typed and flattened Minecraft fields" + ))); + } + let component = if let Some(mut component) = + version_item.inner.components.minecraft_mod.clone() + { + edit.apply_to(&mut component) + .await + .wrap_request_err("applying Minecraft version edit")?; + component + } else { + edit.create().wrap_request_err( + "creating Minecraft version component", + )? + }; + if component.game_versions.is_empty() { + return Err(ApiError::Request(eyre::eyre!( + "at least one Minecraft game version is required" + ))); + } + VersionField::from_minecraft_component(version_id, &component) + .await + .wrap_request_err( + "validating Minecraft version component", + )?; + sqlx::query!( + r#" + UPDATE versions + SET components = jsonb_set(COALESCE(components, '{}'::jsonb), '{minecraft_mod}', $2::jsonb) + WHERE id = $1 + "#, + version_id as database::models::ids::DBVersionId, + sqlx::types::Json(component) as _ + ) + .execute(&mut transaction) + .await + .wrap_internal_err("updating Minecraft version component")?; + } + if !new_version.fields.is_empty() { let version_fields_names = new_version .fields @@ -779,25 +830,6 @@ pub(crate) async fn version_edit_in_transaction( .filter(|lf| version_fields_names.contains(&lf.field)) .collect::>(); - let loader_field_ids = loader_fields - .iter() - .map(|lf| lf.id.0) - .collect::>(); - sqlx::query!( - " - DELETE FROM version_fields - WHERE version_id = $1 - AND field_id = ANY($2) - ", - version_id as database::models::ids::DBVersionId, - &loader_field_ids - ) - .execute(&mut *transaction) - .await - .wrap_internal_err( - "querying database for `version_edit_helper`", - )?; - let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( &loader_fields, diff --git a/apps/labrinth/src/search/indexing.rs b/apps/labrinth/src/search/indexing.rs index 1f5319633d..72a680ad70 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -10,13 +10,9 @@ use std::sync::LazyLock; use tracing::{info, warn}; use crate::database::PgPool; -use crate::database::models::loader_fields::{ - QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, - VersionField, -}; +use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField}; use crate::database::models::{ - DBOrganizationId, DBProjectId, DBUserId, DBVersionId, LoaderFieldEnumId, - LoaderFieldEnumValueId, LoaderFieldId, + DBOrganizationId, DBProjectId, DBUserId, DBVersionId, LoaderFieldEnumId, }; use crate::models::disclosures::ProjectDisclosure; use crate::models::exp; @@ -356,7 +352,7 @@ async fn build_search_documents( .wrap_err("failed to fetch project disclosures")?; info!("Indexing local versions!"); - let mut versions = load_project_versions(pool, project_ids.clone()).await?; + let mut versions = load_project_versions(pool, redis, project_ids.clone()).await?; info!("Indexing local org owners!"); @@ -407,53 +403,6 @@ async fn build_search_documents( }) .await?; - info!("Getting all loader fields!"); - let loader_field_definitions: Vec = sqlx::query!( - " - SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional - FROM loader_fields lf - ", - ) - .fetch(pool) - .map_ok(|m| QueryLoaderField { - id: LoaderFieldId(m.id), - field: m.field, - field_type: m.field_type, - enum_type: m.enum_type.map(LoaderFieldEnumId), - min_val: m.min_val, - max_val: m.max_val, - optional: m.optional, - }) - .try_collect() - .await?; - let loader_field_definitions: Vec<&QueryLoaderField> = - loader_field_definitions.iter().collect(); - - info!("Getting all loader field enum values!"); - - let loader_field_enum_values: Vec = - sqlx::query!( - r#" - SELECT DISTINCT id, enum_id, value, ordering, created, - metadata->>'type' AS "ty?", - (metadata->>'major')::boolean AS "major?" - FROM loader_field_enum_values lfev - ORDER BY enum_id, ordering, created DESC - "# - ) - .fetch(pool) - .map_ok(|m| QueryLoaderFieldEnumValue { - id: LoaderFieldEnumValueId(m.id), - enum_id: LoaderFieldEnumId(m.enum_id), - value: m.value, - ordering: m.ordering, - created: m.created, - ty: m.ty, - major: m.major, - }) - .try_collect() - .await?; - info!("Indexing loaders, project types!"); let mut project_uploads = Vec::new(); let mut version_uploads = Vec::new(); @@ -631,24 +580,26 @@ async fn build_search_documents( .then_with(|| a.id.0.cmp(&b.id.0)) }); - let project_version_fields = versions - .iter() - .flat_map(|x| x.version_fields.clone()) - .collect::>(); - let aggregated_version_fields = VersionField::from_query_json( - project_version_fields, - &loader_field_definitions, - &loader_field_enum_values, - true, - ); - let unvectorized_loader_fields = aggregated_version_fields - .iter() - .map(|field| { - (field.field_name.clone(), field.value.serialize_internal()) - }) - .collect(); - let mut loader_fields = - from_duplicate_version_fields(aggregated_version_fields); + let aggregated_version_fields = versions + .iter() + .flat_map(|version| version.version_fields.clone()) + .collect::>(); + let mut loader_fields = + from_duplicate_version_fields(aggregated_version_fields.clone()); + let mut unvectorized_loader_fields = loader_fields + .iter() + .map(|(name, values)| (name.clone(), serde_json::json!(values))) + .collect::>(); + if let Some(environment) = aggregated_version_fields + .iter() + .rev() + .find(|field| field.field_name == "environment") + { + unvectorized_loader_fields.insert( + "environment".to_owned(), + environment.value.serialize_internal(), + ); + } let project_loader_fields = loader_fields.clone(); let mut project_loaders = versions @@ -677,12 +628,7 @@ async fn build_search_documents( return None; } - let version_fields = VersionField::from_query_json( - version.version_fields.clone(), - &loader_field_definitions, - &loader_field_enum_values, - false, - ); + let version_fields = version.version_fields.clone(); let unvectorized_loader_fields = version_fields .iter() .map(|field| { @@ -849,21 +795,23 @@ struct PartialVersion { id: DBVersionId, loaders: Vec, project_types: Vec, - version_fields: Vec, + version_fields: Vec, date_published: DateTime, } async fn load_project_versions( pool: &PgPool, + redis: &RedisPool, project_ids: Vec, ) -> Result>> { - let versions: HashMap)>> = + let versions: HashMap, exp::version::VersionSerial)>> = sqlx::query!( - " - SELECT v.id, v.mod_id, v.date_published + r#" + SELECT v.id, v.mod_id, v.date_published, + v.components AS "components!: sqlx::types::Json" FROM versions v WHERE mod_id = ANY($1) - ", + "#, &project_ids, ) .fetch(pool) @@ -871,12 +819,12 @@ async fn load_project_versions( HashMap::new(), |mut acc: HashMap< DBProjectId, - Vec<(DBVersionId, DateTime)>, + Vec<(DBVersionId, DateTime, exp::version::VersionSerial)>, >, m| { acc.entry(DBProjectId(m.mod_id)) .or_default() - .push((DBVersionId(m.id), m.date_published)); + .push((DBVersionId(m.id), m.date_published, m.components.0)); async move { Ok(acc) } }, ) @@ -893,7 +841,7 @@ async fn load_project_versions( let all_version_ids = versions .values() .flat_map(|version_ids| version_ids.iter()) - .map(|(version_id, _)| version_id.0) + .map(|(version_id, _, _)| version_id.0) .collect::>(); let loaders_ptypes: DashMap = sqlx::query!( @@ -925,46 +873,21 @@ async fn load_project_versions( .await .wrap_err("failed to fetch loaders and project types")?; - // Get version fields - let version_fields: DashMap> = - sqlx::query!( - " - SELECT version_id, field_id, int_value, enum_value, string_value - FROM version_fields - WHERE version_id = ANY($1) - ", - &all_version_ids, - ) - .fetch(pool) - .try_fold( - DashMap::new(), - |acc: DashMap>, m| { - let qvf = QueryVersionField { - version_id: DBVersionId(m.version_id), - field_id: LoaderFieldId(m.field_id), - int_value: m.int_value, - enum_value: if m.enum_value == -1 { - None - } else { - Some(LoaderFieldEnumValueId(m.enum_value)) - }, - string_value: m.string_value, - }; - - acc.entry(DBVersionId(m.version_id)).or_default().push(qvf); - async move { Ok(acc) } - }, - ) - .await - .wrap_err("failed to fetch version fields")?; - - // Get version fields + let minecraft_catalog = if versions.values().flatten().any(|(_, _, components)| { + components.minecraft_mod.is_some() + }) { + LoaderFieldEnumValue::list(LoaderFieldEnumId(1), pool, redis) + .await + .wrap_err("fetching Minecraft version catalog for search indexing")? + } else { + Vec::new() + }; // Convert to partial versions let mut res_versions: HashMap> = HashMap::new(); for (project_id, version_ids) in &versions { - for (version_id, date_published) in version_ids { + for (version_id, date_published, components) in version_ids { // Extract version-specific data fetched // We use 'remove' as every version is only in the map once let version_loader_data = loaders_ptypes @@ -972,10 +895,17 @@ async fn load_project_versions( .map(|(_, version_loader_data)| version_loader_data) .unwrap_or_default(); - let version_fields = version_fields - .remove(version_id) - .map(|(_, version_fields)| version_fields) - .unwrap_or_default(); + let version_fields = components.minecraft_mod.as_ref() + .map(|component| { + VersionField::from_minecraft_component_with_catalog( + *version_id, + component, + &minecraft_catalog, + ) + }) + .transpose() + .wrap_err_with(|| format!("building search fields for version {}", version_id.0))? + .unwrap_or_default(); res_versions .entry(*project_id) From 19cf51f8e94a9f09f7231db68750407ec87f78f8 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:14:59 +0100 Subject: [PATCH 05/13] wip: remove all loader field references in database module --- .../src/content/docs/contributing/labrinth.md | 2 +- ...7a5f259032c549ebe5d1e6200f0093d9166ca.json | 15 + apps/labrinth/src/background_task.rs | 81 +- .../labrinth/src/database/models/game_item.rs | 82 + apps/labrinth/src/database/models/ids.rs | 3 - .../database/models/legacy_loader_fields.rs | 266 --- .../src/database/models/loader_item.rs | 144 ++ apps/labrinth/src/database/models/mod.rs | 4 +- .../src/database/models/project_item.rs | 33 +- .../src/database/models/version_item.rs | 29 +- apps/labrinth/src/database/redis/mod.rs | 100 +- apps/labrinth/src/env.rs | 1 - apps/labrinth/src/lib.rs | 15 - apps/labrinth/src/main.rs | 18 +- apps/labrinth/src/models/exp/minecraft.rs | 68 +- .../loader_fields.rs => models/minecraft.rs} | 1423 +++++++++-------- apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v2/projects.rs | 91 +- apps/labrinth/src/models/v3/projects.rs | 79 +- apps/labrinth/src/routes/internal/billing.rs | 2 +- apps/labrinth/src/routes/maven.rs | 22 +- apps/labrinth/src/routes/updates.rs | 18 +- apps/labrinth/src/routes/v2/tags.rs | 2 +- apps/labrinth/src/routes/v2/versions.rs | 14 +- .../src/routes/v3/project_creation.rs | 34 +- .../src/routes/v3/project_creation/new.rs | 1 - apps/labrinth/src/routes/v3/tags.rs | 130 +- .../src/routes/v3/version_creation.rs | 56 +- apps/labrinth/src/routes/v3/versions.rs | 69 +- apps/labrinth/src/search/indexing.rs | 204 +-- apps/labrinth/src/test/api_v3/tags.rs | 2 +- apps/labrinth/src/util/tags.rs | 4 +- apps/labrinth/src/util/webhook.rs | 23 +- apps/labrinth/src/validate/fabric.rs | 2 +- apps/labrinth/src/validate/mod.rs | 18 +- apps/labrinth/tests/loader_fields.rs | 3 +- 36 files changed, 1549 insertions(+), 1510 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json create mode 100644 apps/labrinth/src/database/models/game_item.rs delete mode 100644 apps/labrinth/src/database/models/legacy_loader_fields.rs create mode 100644 apps/labrinth/src/database/models/loader_item.rs rename apps/labrinth/src/{database/models/loader_fields.rs => models/minecraft.rs} (57%) diff --git a/apps/docs/src/content/docs/contributing/labrinth.md b/apps/docs/src/content/docs/contributing/labrinth.md index 174d5b03c4..72d0ea4c91 100644 --- a/apps/docs/src/content/docs/contributing/labrinth.md +++ b/apps/docs/src/content/docs/contributing/labrinth.md @@ -76,7 +76,7 @@ The S3 configuration options are fairly self-explanatory in name, so here's simp #### Search, OAuth, and miscellaneous options `LOCAL_INDEX_INTERVAL`: The interval, in seconds, at which the local database is reindexed for searching. Defaults to `3600` seconds (1 hour). -`VERSION_INDEX_INTERVAL`: The interval, in seconds, at which versions are reindexed for searching. Defaults to `1800` seconds (30 minutes). + The OAuth configuration options are fairly self-explanatory. For help setting up authentication, please contact us on [Discord]. diff --git a/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json b/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json new file mode 100644 index 0000000000..1ff37817dd --- /dev/null +++ b/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions SET components = jsonb_set(\n COALESCE(components, '{}'::jsonb), '{minecraft_mod}',\n jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb)\n || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb)\n || $2::jsonb\n ) WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca" +} diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 38ea26e862..bf4e936e2a 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -21,7 +21,6 @@ use xredis::RedisPool; pub enum BackgroundTask { IndexSearch, ReleaseScheduled, - UpdateVersions, IndexBilling, IndexSubscriptions, IncrementalIndexSearch, @@ -64,7 +63,6 @@ impl BackgroundTask { index_search(ro_pool, redis_pool, search_backend).await } ReleaseScheduled => release_scheduled(pool).await, - UpdateVersions => update_versions(pool, redis_pool).await, IndexBilling => { index_billing(stripe_client, anrok_client, pool, redis_pool) .await; @@ -186,18 +184,6 @@ pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> { Ok(()) } -pub async fn update_versions( - pool: PgPool, - redis_pool: RedisPool, -) -> eyre::Result<()> { - info!("Indexing game versions list from Mojang"); - version_updater::update_versions(&pool, &redis_pool) - .await - .wrap_err("failed to update game versions")?; - info!("Done indexing game versions"); - Ok(()) -} - pub async fn discord_role_email_campaign( pool: PgPool, redis_pool: RedisPool, @@ -306,10 +292,10 @@ pub async fn ping_minecraft_java_servers( } mod version_updater { + use std::sync::LazyLock; + use crate::database::PgPool; - use crate::database::models::legacy_loader_fields::{ - MinecraftGameVersion, HALL_OF_SHAME, HALL_OF_SHAME_2, - }; + use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use chrono::{DateTime, Utc}; use serde::Deserialize; use thiserror::Error; @@ -352,6 +338,67 @@ mod version_updater { let mut skipped_versions_count = 0u32; + // A list of version names that contains spaces. + // Generated using the command + // ```sh + // curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ + // | jq '[.versions[].id | select(contains(" "))]' + // ``` + const HALL_OF_SHAME: [(&str, &str); 12] = [ + ("1.14.2 Pre-Release 4", "1.14.2-pre4"), + ("1.14.2 Pre-Release 3", "1.14.2-pre3"), + ("1.14.2 Pre-Release 2", "1.14.2-pre2"), + ("1.14.2 Pre-Release 1", "1.14.2-pre1"), + ("1.14.1 Pre-Release 2", "1.14.1-pre2"), + ("1.14.1 Pre-Release 1", "1.14.1-pre1"), + ("1.14 Pre-Release 5", "1.14-pre5"), + ("1.14 Pre-Release 4", "1.14-pre4"), + ("1.14 Pre-Release 3", "1.14-pre3"), + ("1.14 Pre-Release 2", "1.14-pre2"), + ("1.14 Pre-Release 1", "1.14-pre1"), + ("3D Shareware v1.34", "3D-Shareware-v1.34"), + ]; + + /// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, + /// as most of our ordering logic is with DateTime + static HALL_OF_SHAME_2: LazyLock<[(&'static str, DateTime); 4]> = + LazyLock::new(|| { + [ + ( + "1.4.5", + chrono::DateTime::parse_from_rfc3339( + "2012-12-19T22:00:00+00:00", + ) + .unwrap() + .into(), + ), + ( + "1.4.6", + chrono::DateTime::parse_from_rfc3339( + "2012-12-19T22:00:01+00:00", + ) + .unwrap() + .into(), + ), + ( + "1.6.3", + chrono::DateTime::parse_from_rfc3339( + "2013-09-13T10:54:41+00:00", + ) + .unwrap() + .into(), + ), + ( + "13w37b", + chrono::DateTime::parse_from_rfc3339( + "2013-09-13T10:54:42+00:00", + ) + .unwrap() + .into(), + ), + ] + }); + for version in input.versions.into_iter() { let mut name = version.id; if !name diff --git a/apps/labrinth/src/database/models/game_item.rs b/apps/labrinth/src/database/models/game_item.rs new file mode 100644 index 0000000000..8ed3ce5a91 --- /dev/null +++ b/apps/labrinth/src/database/models/game_item.rs @@ -0,0 +1,82 @@ +use super::ids::GameId; +use eyre::{Result, WrapErr}; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use xredis::RedisPool; + +const GAMES_LIST_NAMESPACE: &str = "games:v4"; +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Game { + pub id: GameId, + pub slug: String, + pub name: String, + pub icon_url: Option, + pub banner_url: Option, +} + +impl Game { + pub async fn get_slug<'a, E>( + slug: &str, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list(exec, redis) + .await + .wrap_err("listing games")? + .into_iter() + .find(|x| x.slug == slug)) + } + + pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + { + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis for cached games")?; + let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); + let cached_games: Option> = redis + .get_deserialized(&key) + .await + .wrap_err("fetching cached games")?; + if let Some(cached_games) = cached_games { + return Ok(cached_games); + } + } + + let result = sqlx::query!( + " + SELECT id, slug, name, icon_url, banner_url FROM games + ", + ) + .fetch(exec) + .map_ok(|x| Game { + id: GameId(x.id), + slug: x.slug, + name: x.name, + icon_url: x.icon_url, + banner_url: x.banner_url, + }) + .try_collect::>() + .await + .wrap_err("fetching games")?; + + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis to cache games")?; + let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); + + redis + .set_serialized(&key, &result, None) + .await + .wrap_err("caching games")?; + + Ok(result) + } +} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 7ca5fcb7cd..50ceb0a2fb 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -292,9 +292,6 @@ db_id_interface!( id_type!(CategoryId as i32); id_type!(GameId as i32); -id_type!(LoaderFieldEnumId as i32); -id_type!(LoaderFieldEnumValueId as i32); -id_type!(LoaderFieldId as i32); id_type!(LoaderId as i32); id_type!(NotificationActionId as i32); id_type!(ProjectTypeId as i32); diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs deleted file mode 100644 index 86e022e604..0000000000 --- a/apps/labrinth/src/database/models/legacy_loader_fields.rs +++ /dev/null @@ -1,266 +0,0 @@ -// In V3, we switched to dynamic loader fields for a better support for more loaders, games, and potential metadata. -// This file contains the legacy loader fields, which are still used by V2 projects. -// They are still useful to have in several places where minecraft-java functionality is hardcoded- for example, -// for fetching data from forge, maven, etc. -// These fields only apply to minecraft-java, and are hardcoded to the minecraft-java game. - -use std::sync::LazyLock; - -use chrono::{DateTime, Utc}; -use eyre::{Result, WrapErr}; -use itertools::Itertools; -use serde::{Deserialize, Serialize}; - -use xredis::RedisPool; - -use super::{ - LoaderFieldEnumId, LoaderFieldEnumValueId, - loader_fields::{ - LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue, - }, -}; - -/// Mojang version names that require aliases for historical Labrinth compatibility. -// A list of version names that contains spaces. -// Generated using the command -// ```sh -// curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ -// | jq '[.versions[].id | select(contains(" "))]' -// ``` -pub(crate) const HALL_OF_SHAME: [(&str, &str); 12] = [ - ("1.14.2 Pre-Release 4", "1.14.2-pre4"), - ("1.14.2 Pre-Release 3", "1.14.2-pre3"), - ("1.14.2 Pre-Release 2", "1.14.2-pre2"), - ("1.14.2 Pre-Release 1", "1.14.2-pre1"), - ("1.14.1 Pre-Release 2", "1.14.1-pre2"), - ("1.14.1 Pre-Release 1", "1.14.1-pre1"), - ("1.14 Pre-Release 5", "1.14-pre5"), - ("1.14 Pre-Release 4", "1.14-pre4"), - ("1.14 Pre-Release 3", "1.14-pre3"), - ("1.14 Pre-Release 2", "1.14-pre2"), - ("1.14 Pre-Release 1", "1.14-pre1"), - ("3D Shareware v1.34", "3D-Shareware-v1.34"), -]; - -/// Preserves historical Labrinth aliases while leaving other catalog names unchanged. -pub(crate) fn legacy_game_version_name(name: &str) -> &str { - HALL_OF_SHAME - .iter() - .find_map(|(canonical, legacy)| (*canonical == name).then_some(*legacy)) - .unwrap_or(name) -} - -/// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, -/// as most of our ordering logic is with DateTime -pub(crate) static HALL_OF_SHAME_2: LazyLock<[(&str, DateTime); 4]> = - LazyLock::new(|| { - [ - ("1.4.5", "2012-12-19T22:00:00+00:00"), - ("1.4.6", "2012-12-19T22:00:01+00:00"), - ("1.6.3", "2013-09-13T10:54:41+00:00"), - ("13w37b", "2013-09-13T10:54:42+00:00"), - ] - .map(|(version, date)| { - ( - version, - DateTime::parse_from_rfc3339(date) - .unwrap() - .with_timezone(&Utc), - ) - }) - }); - -/// Preserves the historical ordering of releases with identical upstream timestamps. -pub(crate) fn legacy_game_version_date( - name: &str, - date: DateTime, -) -> DateTime { - HALL_OF_SHAME_2 - .iter() - .find_map(|(version, corrected)| { - (*version == name).then_some(*corrected) - }) - .unwrap_or(date) -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct MinecraftGameVersion { - pub id: LoaderFieldEnumValueId, - pub version: String, - #[serde(rename = "type")] - pub type_: String, - pub created: DateTime, - pub major: bool, -} - -impl MinecraftGameVersion { - // The name under which this legacy field is stored as a LoaderField - pub const FIELD_NAME: &'static str = "game_versions"; - - pub fn builder() -> MinecraftGameVersionBuilder<'static> { - MinecraftGameVersionBuilder::default() - } - - pub async fn list<'a, E>( - version_type_option: Option<&str>, - major_option: Option, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Acquire<'a, Database = sqlx::Postgres>, - { - let mut exec = exec - .acquire() - .await - .wrap_err("acquiring database connection")?; - let game_version_enum = - LoaderFieldEnum::get(Self::FIELD_NAME, &mut exec, redis) - .await - .wrap_err("fetching game version enum")? - .ok_or_else(|| eyre::eyre!("Could not find game version enum.")) - .wrap_err("finding game version enum")?; - let game_version_enum_values = - LoaderFieldEnumValue::list(game_version_enum.id, &mut exec, redis) - .await - .wrap_err("fetching game version enum values")?; - - let game_versions = game_version_enum_values - .into_iter() - .map(MinecraftGameVersion::from_enum_value) - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = version_type_option { - bool &= &*x.type_ == version_type; - } - if let Some(major) = major_option { - bool &= x.major == major; - } - - bool - }) - .collect_vec(); - - Ok(game_versions) - } - - // Tries to create a MinecraftGameVersion from a VersionField - // Clones on success - pub fn try_from_version_field( - version_field: &VersionField, - ) -> Result> { - if version_field.field_name != Self::FIELD_NAME { - return Err(eyre::eyre!( - "Field name {} is not {}", - version_field.field_name, - Self::FIELD_NAME - )); - } - let game_versions = match version_field.clone() { - VersionField { - value: VersionFieldValue::ArrayEnum(_, values), - .. - } => values.into_iter().map(Self::from_enum_value).collect(), - VersionField { - value: VersionFieldValue::Enum(_, value), - .. - } => { - vec![Self::from_enum_value(value)] - } - _ => { - return Err(eyre::eyre!( - "Game version requires field value to be an enum: {version_field:?}" - )); - } - }; - Ok(game_versions) - } - - pub fn from_enum_value( - loader_field_enum_value: LoaderFieldEnumValue, - ) -> MinecraftGameVersion { - MinecraftGameVersion { - id: loader_field_enum_value.id, - version: loader_field_enum_value.value, - created: loader_field_enum_value.created, - type_: loader_field_enum_value.ty.unwrap_or_default(), - major: loader_field_enum_value.major.unwrap_or_default(), - } - } -} - -#[derive(Default)] -pub struct MinecraftGameVersionBuilder<'a> { - pub version: Option<&'a str>, - pub version_type: Option<&'a str>, - pub date: Option<&'a DateTime>, -} - -impl<'a> MinecraftGameVersionBuilder<'a> { - pub fn new() -> Self { - Self::default() - } - /// The game version. Spaces must be replaced with '_' for it to be valid - pub fn version( - self, - version: &'a str, - ) -> Result> { - Ok(Self { - version: Some(version), - ..self - }) - } - - pub fn version_type( - self, - version_type: &'a str, - ) -> Result> { - Ok(Self { - version_type: Some(version_type), - ..self - }) - } - - pub fn created( - self, - created: &'a DateTime, - ) -> MinecraftGameVersionBuilder<'a> { - Self { - date: Some(created), - ..self - } - } - - /// Verifies an entry owned by the metadata generator; never creates incomplete catalog rows. - pub async fn insert<'b, E>( - self, - exec: E, - redis: &RedisPool, - ) -> Result - where - E: crate::database::Executor<'b, Database = sqlx::Postgres> + Copy, - { - let version = self - .version - .ok_or_else(|| eyre::eyre!("missing Minecraft version name"))?; - let values = - LoaderFieldEnumValue::list(LoaderFieldEnumId(1), exec, redis) - .await?; - let existing = values.into_iter().find(|value| value.value == version) - .ok_or_else(|| eyre::eyre!("Minecraft version `{version}` is absent from the metadata catalog; ingest it with the metadata generator"))?; - if let Some(ty) = self.version_type { - eyre::ensure!( - existing.ty.as_deref() == Some(ty), - "Minecraft version `{version}` type differs from the metadata catalog; update it with the metadata generator" - ); - } - if let Some(date) = self.date { - eyre::ensure!( - existing.created == *date, - "Minecraft version `{version}` release date differs from the metadata catalog; update it with the metadata generator" - ); - } - Ok(existing.id) - } -} diff --git a/apps/labrinth/src/database/models/loader_item.rs b/apps/labrinth/src/database/models/loader_item.rs new file mode 100644 index 0000000000..2f19d50df3 --- /dev/null +++ b/apps/labrinth/src/database/models/loader_item.rs @@ -0,0 +1,144 @@ +use super::ids::LoaderId; +use eyre::{Result, WrapErr}; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use xredis::RedisPool; + +const LOADER_ID: &str = "loader_id:v4"; +const LOADERS_LIST_NAMESPACE: &str = "loaders:v4"; + +#[derive(Clone, Serialize, Deserialize)] +pub struct LoaderMetadata { + pub platform: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Loader { + pub id: LoaderId, + pub loader: String, + pub icon: String, + pub supported_project_types: Vec, + pub supported_games: Vec, // slugs + pub metadata: LoaderMetadata, +} + +impl Loader { + pub async fn get_id<'a, E>( + name: &str, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + { + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis for cached loader id")?; + let key = redis.key().metadata(LOADER_ID, name); + let cached_id: Option = redis + .get_deserialized(&key) + .await + .wrap_err("fetching cached loader id")?; + if let Some(cached_id) = cached_id { + return Ok(Some(LoaderId(cached_id))); + } + } + + let result = sqlx::query!( + " + SELECT id FROM loaders + WHERE loader = $1 + ", + name + ) + .fetch_optional(exec) + .await + .wrap_err("fetching loader id")? + .map(|r| LoaderId(r.id)); + + if let Some(result) = result { + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis to cache loader id")?; + let key = redis.key().metadata(LOADER_ID, name); + redis + .set_serialized(&key, &result.0, None) + .await + .wrap_err("caching loader id")?; + } + + Ok(result) + } + + pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + { + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis for cached loaders")?; + let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all"); + let cached_loaders: Option> = redis + .get_deserialized(&key) + .await + .wrap_err("fetching cached loaders")?; + if let Some(cached_loaders) = cached_loaders { + return Ok(cached_loaders); + } + } + + let result = sqlx::query!( + " + SELECT l.id id, l.loader loader, l.icon icon, + (l.metadata->>'platform')::boolean AS platform, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games + FROM loaders l + LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id + LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id + LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id + LEFT OUTER JOIN games g ON lptg.game_id = g.id + GROUP BY l.id; + ", + ) + .fetch(exec) + .map_ok(|x| Loader { + id: LoaderId(x.id), + loader: x.loader, + icon: x.icon, + supported_project_types: x + .project_types + .unwrap_or_default() + .iter() + .map(|x| x.to_string()) + .collect(), + supported_games: x + .games + .unwrap_or_default(), + metadata: LoaderMetadata { + platform: x.platform, + }, + }) + .try_collect::>() + .await + .wrap_err("fetching loaders")?; + + let mut redis = redis + .connect() + .await + .wrap_err("connecting to redis to cache loaders")?; + let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all"); + + redis + .set_serialized(&key, &result, None) + .await + .wrap_err("caching loaders")?; + + Ok(result) + } +} diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 850ab58493..9907699ac1 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -6,10 +6,10 @@ pub mod collection_item; pub mod delphi_report_item; pub mod flow_item; pub mod friend_item; +pub mod game_item; pub mod ids; pub mod image_item; -pub mod legacy_loader_fields; -pub mod loader_fields; +pub mod loader_item; pub mod moderation_external_item; pub mod moderation_lock_item; pub mod moderation_note_item; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index be5f88f12b..6b5751da09 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -1,4 +1,3 @@ -use super::loader_fields::{LoaderFieldEnumValue, VersionField, minecraft_metadata}; use super::{DBUser, ids::*}; use crate::database::{PgTransaction, models}; @@ -21,8 +20,8 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -pub const PROJECTS_NAMESPACE: &str = "projects:v6"; -pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v5"; +pub const PROJECTS_NAMESPACE: &str = "projects:v7"; +pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v6"; const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4"; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -645,14 +644,8 @@ impl DBProject { .map(|x| x.to_string().to_lowercase()) .collect::>(); - let minecraft_catalog = LoaderFieldEnumValue::minecraft_catalog( - minecraft_metadata().await.wrap_err("connecting to minecraft metadata")?, - ) - .await - .wrap_err("fetching minecraft version catalog")?; - let all_version_ids = DashSet::new(); - let version_fields: DashMap> = DashMap::new(); + let minecraft_mod_versions: DashMap> = DashMap::new(); let versions: DashMap)>> = sqlx::query!( r#" SELECT DISTINCT mod_id, v.id as id, date_published, @@ -673,22 +666,20 @@ impl DBProject { .try_fold( DashMap::new(), |acc: DashMap)>>, m| { - let result = (|| { + let result = { let version_id = DBVersionId(m.id); let date_published = m.date_published; all_version_ids.insert(version_id); - if let Some(component) = m.components.0.minecraft_mod.as_ref() { - version_fields.entry(DBProjectId(m.mod_id)).or_default() - .extend(VersionField::from_minecraft_component_with_catalog( - version_id, component, &minecraft_catalog, - ).wrap_err("converting project version minecraft component")?); + if let Some(component) = m.components.0.minecraft_mod { + minecraft_mod_versions.entry(DBProjectId(m.mod_id)).or_default() + .push(component); } acc.entry(DBProjectId(m.mod_id)) .or_default() .push((version_id, date_published)); eyre::Ok(acc) - })(); - async move { result } + }; + async move { result } }, ) .await @@ -852,7 +843,7 @@ impl DBProject { versions.sort_by_key(|a| a.1); let mut gallery = mods_gallery.remove(&project_id).map(|x| x.1).unwrap_or_default(); let urls = links.remove(&project_id).map(|x| x.1).unwrap_or_default(); - let version_fields = version_fields.remove(&project_id).map(|x| x.1).unwrap_or_default(); + let minecraft_mod_versions = minecraft_mod_versions.remove(&project_id).map(|x| x.1).unwrap_or_default(); let components_serial = m.components.0; exp::compat::correct_project_types( @@ -914,7 +905,7 @@ impl DBProject { gallery }, urls, - aggregate_version_fields: version_fields, + minecraft_mod_versions, thread_id: DBThreadId(m.thread_id), components, }; @@ -1052,6 +1043,6 @@ pub struct ProjectQueryResult { pub urls: Vec, pub gallery_items: Vec, pub thread_id: DBThreadId, - pub aggregate_version_fields: Vec, + pub minecraft_mod_versions: Vec, pub components: exp::ProjectQuery, } diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 0d0ee63300..7df7f39145 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -1,5 +1,4 @@ use super::ids::*; -use super::loader_fields::{LoaderFieldEnumValue, VersionField, minecraft_metadata}; use crate::database::PgTransaction; use crate::file_hosting::FileHost; @@ -19,7 +18,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use tracing::error; -pub const VERSIONS_NAMESPACE: &str = "versions:v5"; +pub const VERSIONS_NAMESPACE: &str = "versions:v6"; const VERSION_FILES_NAMESPACE: &str = "versions_files:v4"; pub async fn cleanup_unused_attribution_files_and_groups( @@ -72,7 +71,6 @@ pub struct VersionBuilder { pub files: Vec, pub dependencies: Vec, pub loaders: Vec, - pub version_fields: Vec, pub version_type: String, pub featured: bool, pub status: VersionStatus, @@ -344,10 +342,6 @@ impl VersionBuilder { .await .wrap_err("inserting version loaders")?; - VersionField::insert_many(self.version_fields, transaction) - .await - .wrap_err("inserting version fields")?; - Ok(self.version_id) } } @@ -648,12 +642,6 @@ impl DBVersion { .await .wrap_err("acquiring database connection")?; - let minecraft_catalog = LoaderFieldEnumValue::minecraft_catalog( - minecraft_metadata().await.wrap_err("connecting to minecraft metadata")?, - ) - .await - .wrap_err("fetching minecraft version catalog")?; - #[derive(Default)] struct VersionLoaderData { loaders: Vec, @@ -826,7 +814,7 @@ impl DBVersion { .fetch(&mut exec) .map_err(eyre::Report::from) .try_fold(DashMap::new(), |acc, v| { - let result = (|| { + let result = { let version_id = DBVersionId(v.id); let VersionLoaderData { loaders, @@ -851,13 +839,6 @@ impl DBVersion { .collect_vec(); let components_serial = v.components.0; - let version_fields = components_serial.minecraft_mod.as_ref() - .map(|component| VersionField::from_minecraft_component_with_catalog( - version_id, component, &minecraft_catalog, - )) - .transpose() - .wrap_err("converting version minecraft component")? - .unwrap_or_default(); let query_version = VersionQueryResult { inner: DBVersion { id: DBVersionId(v.id), @@ -912,7 +893,6 @@ impl DBVersion { files }, - version_fields, loaders, project_types, games, @@ -923,8 +903,8 @@ impl DBVersion { acc.insert(v.id, query_version); eyre::Ok(acc) - })(); - async move { result } + }; + async move { result } }) .await .wrap_err("fetching versions")?; @@ -1088,7 +1068,6 @@ pub struct VersionQueryResult { pub inner: DBVersion, pub files: Vec, - pub version_fields: Vec, pub loaders: Vec, pub project_types: Vec, pub games: Vec, diff --git a/apps/labrinth/src/database/redis/mod.rs b/apps/labrinth/src/database/redis/mod.rs index 5dc3cca9cb..c77b924f98 100644 --- a/apps/labrinth/src/database/redis/mod.rs +++ b/apps/labrinth/src/database/redis/mod.rs @@ -72,13 +72,9 @@ mod tests { use crate::database::models::flow_item::DBFlow; use crate::database::models::ids::{ DBNotificationId, DBProjectId, DBTeamId, DBThreadId, DBUserId, - DBVersionId, LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId, - LoaderId, - }; - use crate::database::models::loader_fields::{ - Loader, LoaderFieldEnumValue, LoaderMetadata, VersionField, - VersionFieldValue, + DBVersionId, LoaderId, }; + use crate::database::models::loader_item::{Loader, LoaderMetadata}; use crate::database::models::notification_item::DBNotification; use crate::database::models::project_item::{ DBProject, ProjectQueryResult, @@ -103,18 +99,6 @@ mod tests { postcard::from_bytes(&serialized).expect("deserializing with postcard") } - fn loader_field_enum_value(ty: &str, major: bool) -> LoaderFieldEnumValue { - LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(1), - enum_id: LoaderFieldEnumId(2), - value: "1.21.8".to_string(), - ordering: None, - created: Utc::now(), - ty: Some(ty.to_string()), - major: Some(major), - } - } - fn db_project() -> DBProject { let now = Utc::now(); @@ -204,89 +188,11 @@ mod tests { } } - #[test] - fn loader_field_enum_values_round_trip_with_postcard() { - let metadata_values = [ - (None, None), - (Some("snapshot"), Some(false)), - (Some("alpha"), Some(false)), - (Some("beta"), Some(true)), - (Some("release"), Some(true)), - (Some("beta"), Some(false)), - (Some("release"), Some(false)), - ]; - - for (ty, major) in metadata_values { - let enum_value = LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(1), - enum_id: LoaderFieldEnumId(2), - value: "1.21.8".to_string(), - ordering: None, - created: Utc::now(), - ty: ty.map(str::to_string), - major, - }; - - assert_eq!(postcard_round_trip(&enum_value), enum_value); - } - } - - #[test] - fn loader_field_enum_value_keeps_flattened_json_layout() { - let enum_value = loader_field_enum_value("release", true); - let json = serde_json::to_value(&enum_value) - .expect("serializing loader field enum value as JSON"); - - assert_eq!(json.get("type"), Some(&serde_json::json!("release"))); - assert_eq!(json.get("major"), Some(&serde_json::json!(true))); - assert!(json.get("metadata").is_none()); - assert_eq!( - serde_json::from_value::(json) - .expect("deserializing loader field enum value from JSON"), - enum_value - ); - } - - #[test] - fn non_enum_version_fields_round_trip_with_postcard() { - let values = [ - VersionFieldValue::Integer(5), - VersionFieldValue::Text("value".to_string()), - VersionFieldValue::Boolean(true), - VersionFieldValue::ArrayInteger(vec![1, 2]), - VersionFieldValue::ArrayText(vec!["one".to_string()]), - VersionFieldValue::ArrayBoolean(vec![true, false]), - ]; - - for value in values { - let field = VersionField { - version_id: DBVersionId(1), - field_id: LoaderFieldId(2), - field_name: "field".to_string(), - value, - }; - - assert_eq!(postcard_round_trip(&field), field); - } - } - #[test] fn flattened_cache_values_round_trip_with_postcard() { - let enum_value = loader_field_enum_value("release", true); - postcard_round_trip(&enum_value); - let version = VersionQueryResult { inner: db_version(), files: Vec::new(), - version_fields: vec![VersionField { - version_id: DBVersionId(1), - field_id: LoaderFieldId(2), - field_name: "game_versions".to_string(), - value: VersionFieldValue::Enum( - LoaderFieldEnumId(2), - enum_value, - ), - }], loaders: vec!["fabric".to_string()], project_types: vec!["mod".to_string()], games: vec!["minecraft-java".to_string()], @@ -305,7 +211,7 @@ mod tests { urls: Vec::new(), gallery_items: Vec::new(), thread_id: DBThreadId(1), - aggregate_version_fields: Vec::new(), + minecraft_mod_versions: Vec::new(), components: exp::ProjectQuery::default(), }; postcard_round_trip(&project); diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 67027ad6b0..969ea866a8 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -187,7 +187,6 @@ vars! { SELF_ADDR: String = ""; LOCAL_INDEX_INTERVAL: u64 = 3600u64; - VERSION_INDEX_INTERVAL: u64 = 1800u64; WHITELISTED_MODPACK_DOMAINS: Json> = Json(vec![ "cdn.modrinth.com".into(), diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 327cd7ad3a..e0c569f9ac 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -16,7 +16,6 @@ use clickhouse_crate::Client; use util::cors::default_cors; use util::gotenberg::GotenbergClient; -use crate::background_task::update_versions; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::env::ENV; use crate::queue::billing::{index_billing, index_subscriptions}; @@ -149,20 +148,6 @@ pub fn app_setup( } }); - let version_index_interval = - Duration::from_secs(ENV.VERSION_INDEX_INTERVAL); - let pool_ref = pool.clone(); - let redis_pool_ref = redis_pool.clone(); - scheduler.run(version_index_interval, move || { - let pool_ref = pool_ref.clone(); - let redis = redis_pool_ref.clone(); - async move { - if let Err(e) = update_versions(pool_ref, redis).await { - warn!("Version update failed: {e:#}"); - } - } - }); - let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); let stripe_client_ref = stripe_client.clone(); diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 069a0da638..6ac0bc74b7 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -125,13 +125,15 @@ async fn app() -> std::io::Result<()> { let (pool, ro_pool) = database::connect_all() .await .expect("Database connection failed"); - let minecraft_meta_db = actix_web::web::Data::new( - database::MinecraftMetaDb::connect() - .await - .expect("Minecraft metadata database connection failed"), - ); - database::models::loader_fields::register_minecraft_metadata(minecraft_meta_db.clone()) - .expect("Minecraft metadata service registration failed"); + let minecraft_meta_db = actix_web::web::Data::new( + database::MinecraftMetaDb::connect() + .await + .expect("Minecraft metadata database connection failed"), + ); + labrinth::models::minecraft::register_minecraft_metadata( + minecraft_meta_db.clone(), + ) + .expect("Minecraft metadata service registration failed"); // Redis connector let redis_pool = redis::from_env("").await; @@ -234,7 +236,7 @@ async fn app() -> std::io::Result<()> { let labrinth_config = labrinth::app_setup( pool.clone(), ro_pool.clone(), - minecraft_meta_db, + minecraft_meta_db, redis_pool.clone(), search_backend.clone(), &mut clickhouse, diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 2f99a30d37..23d390b327 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -12,11 +12,11 @@ use crate::{ models::{ exp::{ ProjectComponentKind, - version::{VersionComponent, VersionComponentKind}, component::{self, Component, ComponentEdit, ComponentQuery}, project::{ ProjectComponent, ProjectQueryContext, ProjectQueryRequirements, }, + version::{VersionComponent, VersionComponentKind}, }, ids::{ProjectId, VersionId}, }, @@ -124,6 +124,7 @@ pub enum Language { utoipa::ToSchema, strum::IntoStaticStr, strum::EnumString, + strum::EnumIter, )] #[serde(rename_all = "snake_case")] #[strum( @@ -158,6 +159,7 @@ pub enum Environment { utoipa::ToSchema, strum::IntoStaticStr, strum::EnumString, + strum::EnumIter, )] #[serde(rename_all = "lowercase")] #[strum( @@ -220,31 +222,31 @@ fn invalid_minecraft_metadata_value(value: &str) -> eyre::Report { component::define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - /// Umbrella for legacy Minecraft mods, modpacks, plugins, data packs, - /// resource packs, and shaders; not restricted to the legacy mod type. - pub struct ModProject {} - - /// Shared version metadata for Minecraft mods, modpacks, plugins, data packs, - /// resource packs, and shaders. - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ModVersion: version { - #[base(schema(value_type = Vec))] - #[edit(serde(default), schema(value_type = Option>))] - #[create(required)] - pub game_versions: Vec, - #[base(serde(default))] - #[edit(serde( - default, - skip_serializing_if = "Option::is_none", - with = "serde_with::rust::double_option" - ))] - #[create(optional)] - pub environment: Option, - #[base(serde(default))] - #[edit(serde(default))] - #[create(default)] - pub mrpack_loaders: Vec, - } + /// Umbrella for legacy Minecraft mods, modpacks, plugins, data packs, + /// resource packs, and shaders; not restricted to the legacy mod type. + pub struct ModProject {} + + /// Shared version metadata for Minecraft mods, modpacks, plugins, data packs, + /// resource packs, and shaders. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ModVersion: version { + #[base(schema(value_type = Vec))] + #[edit(serde(default), schema(value_type = Option>))] + #[create(required)] + pub game_versions: Vec, + #[base(serde(default))] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + #[create(optional)] + pub environment: Option, + #[base(serde(default))] + #[edit(serde(default))] + #[create(default)] + pub mrpack_loaders: Vec, + } /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -295,7 +297,7 @@ component::define! { /// Version of a Minecraft Java server listing. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct JavaServerVersion: version {} + pub struct JavaServerVersion: version {} /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -310,15 +312,15 @@ component::define! { } impl VersionComponent for ModVersion { - fn kind() -> VersionComponentKind { - VersionComponentKind::MinecraftMod - } + fn kind() -> VersionComponentKind { + VersionComponentKind::MinecraftMod + } } impl VersionComponent for JavaServerVersion { - fn kind() -> VersionComponentKind { - VersionComponentKind::MinecraftJavaServer - } + fn kind() -> VersionComponentKind { + VersionComponentKind::MinecraftJavaServer + } } impl ProjectComponent for ModProject { diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/models/minecraft.rs similarity index 57% rename from apps/labrinth/src/database/models/loader_fields.rs rename to apps/labrinth/src/models/minecraft.rs index d8e2c28597..09ba685310 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/models/minecraft.rs @@ -1,230 +1,64 @@ +//! Minecraft compatibility adapters for legacy API consumers. + use std::collections::HashMap; use std::hash::Hasher; - -use super::ids::*; -use crate::database::PgTransaction; -use chrono::DateTime; -use chrono::Utc; +use std::sync::LazyLock; + +use crate::database::{ + PgTransaction, + models::{ + ids::{DBVersionId, LoaderId}, + loader_item::Loader, + }, +}; +use crate::models::exp; +use chrono::{DateTime, Utc}; use eyre::{Result, WrapErr}; -use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const GAMES_LIST_NAMESPACE: &str = "games:v4"; -const LOADER_ID: &str = "loader_id:v4"; -const LOADERS_LIST_NAMESPACE: &str = "loaders:v4"; -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Game { - pub id: GameId, - pub slug: String, - pub name: String, - pub icon_url: Option, - pub banner_url: Option, -} - -impl Game { - pub async fn get_slug<'a, E>( - slug: &str, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list(exec, redis) - .await - .wrap_err("listing games")? - .into_iter() - .find(|x| x.slug == slug)) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached games")?; - let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); - let cached_games: Option> = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached games")?; - if let Some(cached_games) = cached_games { - return Ok(cached_games); - } - } - - let result = sqlx::query!( - " - SELECT id, slug, name, icon_url, banner_url FROM games - ", - ) - .fetch(exec) - .map_ok(|x| Game { - id: GameId(x.id), - slug: x.slug, - name: x.name, - icon_url: x.icon_url, - banner_url: x.banner_url, - }) - .try_collect::>() - .await - .wrap_err("fetching games")?; - - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache games")?; - let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); - - redis - .set_serialized(&key, &result, None) - .await - .wrap_err("caching games")?; - - Ok(result) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct LoaderMetadata { - pub platform: Option, -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct Loader { - pub id: LoaderId, - pub loader: String, - pub icon: String, - pub supported_project_types: Vec, - pub supported_games: Vec, // slugs - pub metadata: LoaderMetadata, -} - -impl Loader { - pub async fn get_id<'a, E>( - name: &str, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached loader id")?; - let key = redis.key().metadata(LOADER_ID, name); - let cached_id: Option = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached loader id")?; - if let Some(cached_id) = cached_id { - return Ok(Some(LoaderId(cached_id))); - } - } - - let result = sqlx::query!( - " - SELECT id FROM loaders - WHERE loader = $1 - ", - name - ) - .fetch_optional(exec) - .await - .wrap_err("fetching loader id")? - .map(|r| LoaderId(r.id)); - - if let Some(result) = result { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache loader id")?; - let key = redis.key().metadata(LOADER_ID, name); - redis - .set_serialized(&key, &result.0, None) - .await - .wrap_err("caching loader id")?; - } - - Ok(result) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached loaders")?; - let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all"); - let cached_loaders: Option> = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached loaders")?; - if let Some(cached_loaders) = cached_loaders { - return Ok(cached_loaders); - } - } - - let result = sqlx::query!( - " - SELECT l.id id, l.loader loader, l.icon icon, - (l.metadata->>'platform')::boolean AS platform, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games - FROM loaders l - LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id - LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id - LEFT OUTER JOIN games g ON lptg.game_id = g.id - GROUP BY l.id; - ", - ) - .fetch(exec) - .map_ok(|x| Loader { - id: LoaderId(x.id), - loader: x.loader, - icon: x.icon, - supported_project_types: x - .project_types - .unwrap_or_default() - .iter() - .map(|x| x.to_string()) - .collect(), - supported_games: x - .games - .unwrap_or_default(), - metadata: LoaderMetadata { - platform: x.platform, - }, - }) - .try_collect::>() - .await - .wrap_err("fetching loaders")?; - - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache loaders")?; - let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all"); - - redis - .set_serialized(&key, &result, None) - .await - .wrap_err("caching loaders")?; - - Ok(result) - } -} +/// Synthetic Minecraft compatibility identifier, not a database row ID. +#[derive( + Copy, + Clone, + Debug, + Serialize, + Deserialize, + Eq, + PartialEq, + Hash, + utoipa::ToSchema, +)] +pub struct LoaderFieldId(pub i32); + +/// Synthetic Minecraft compatibility identifier, not a database row ID. +#[derive( + Copy, + Clone, + Debug, + Serialize, + Deserialize, + Eq, + PartialEq, + Hash, + utoipa::ToSchema, +)] +pub struct LoaderFieldEnumId(pub i32); + +/// Synthetic Minecraft compatibility identifier, not a database row ID. +#[derive( + Copy, + Clone, + Debug, + Serialize, + Deserialize, + Eq, + PartialEq, + Hash, + utoipa::ToSchema, +)] +pub struct LoaderFieldEnumValueId(pub i32); #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderField { @@ -397,519 +231,544 @@ pub struct QueryLoaderFieldEnumValue { /// Compatibility IDs are schema identifiers, not IDs from the retired tables. fn minecraft_fields() -> Vec { - [ - (1, "game_versions", false, true), - (2, "environment", true, false), - (3, "mrpack_loaders", true, true), - ] - .into_iter() - .map(|(id, field, optional, array)| LoaderField { - id: LoaderFieldId(id), - field: field.into(), - optional, - field_type: if array { - LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) - } else { - LoaderFieldType::Enum(LoaderFieldEnumId(id)) - }, - min_val: if id == 1 { Some(1) } else { None }, - max_val: None, - }) - .collect() + [ + (1, "game_versions", false, true), + (2, "environment", true, false), + (3, "mrpack_loaders", true, true), + ] + .into_iter() + .map(|(id, field, optional, array)| LoaderField { + id: LoaderFieldId(id), + field: field.into(), + optional, + field_type: if array { + LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) + } else { + LoaderFieldType::Enum(LoaderFieldEnumId(id)) + }, + min_val: if id == 1 { Some(1) } else { None }, + max_val: None, + }) + .collect() } static MINECRAFT_METADATA: std::sync::LazyLock< - tokio::sync::OnceCell>, + tokio::sync::OnceCell>, > = std::sync::LazyLock::new(tokio::sync::OnceCell::new); /// Register the application's existing metadata service before compatibility lookups. /// Call once during setup with `minecraft_meta_db.clone()`. pub fn register_minecraft_metadata( - metadata: actix_web::web::Data, + metadata: actix_web::web::Data, ) -> Result<()> { - MINECRAFT_METADATA.set(metadata.into_inner()).map_err(|_| { - eyre::eyre!("Minecraft metadata service is already initialized") - }) + MINECRAFT_METADATA.set(metadata.into_inner()).map_err(|_| { + eyre::eyre!("Minecraft metadata service is already initialized") + }) } pub(crate) async fn minecraft_metadata() -> Result<&'static crate::database::MinecraftMetaDb> { - Ok(MINECRAFT_METADATA - .get_or_try_init(|| async { - crate::database::MinecraftMetaDb::connect() - .await - .map(std::sync::Arc::new) - }) - .await? - .as_ref()) + Ok(MINECRAFT_METADATA + .get_or_try_init(|| async { + crate::database::MinecraftMetaDb::connect() + .await + .map(std::sync::Arc::new) + }) + .await? + .as_ref()) } -fn compatibility_value(enum_id: i32, value: String) -> LoaderFieldEnumValue { - // FNV-1a is deterministic across processes and catalog ordering changes. - let hash = value.bytes().fold(2166136261u32, |hash, byte| { - (hash ^ u32::from(byte)).wrapping_mul(16777619) - }); - LoaderFieldEnumValue { - id: LoaderFieldEnumValueId((hash & 0x7fff_ffff) as i32), - enum_id: LoaderFieldEnumId(enum_id), - value, - ordering: None, - created: DateTime::::UNIX_EPOCH, - ty: None, - major: None, - } +pub(crate) fn compatibility_value( + enum_id: i32, + value: String, +) -> LoaderFieldEnumValue { + // FNV-1a is deterministic across processes and catalog ordering changes. + let hash = value.bytes().fold(2166136261u32, |hash, byte| { + (hash ^ u32::from(byte)).wrapping_mul(16777619) + }); + LoaderFieldEnumValue { + id: LoaderFieldEnumValueId((hash & 0x7fff_ffff) as i32), + enum_id: LoaderFieldEnumId(enum_id), + value, + ordering: None, + created: DateTime::::UNIX_EPOCH, + ty: None, + major: None, + } } impl LoaderField { - pub async fn get_field<'a, E>( - field: &str, - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::get_fields(loader_ids, exec, redis) - .await? - .into_iter() - .find(|item| item.field == field)) - } - pub async fn get_fields<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::get_fields_per_loader(loader_ids, exec, redis) - .await? - .into_values() - .flatten() - .unique_by(|field| field.id) - .collect()) - } - pub async fn get_fields_per_loader<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Loader::list(exec, redis) - .await? - .into_iter() - .filter(|loader| loader_ids.contains(&loader.id)) - .map(|loader| { - let fields = minecraft_fields() - .into_iter() - .filter(|field| { - loader - .supported_games - .iter() - .any(|game| game == "minecraft-java") - && (field.field != "mrpack_loaders" - || loader.loader == "mrpack") - }) - .collect(); - (loader.id, fields) - }) - .collect()) - } - pub async fn get_fields_all<'a, E>( - _exec: E, - _redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(minecraft_fields()) - } + pub async fn get_field<'a, E>( + field: &str, + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::get_fields(loader_ids, exec, redis) + .await? + .into_iter() + .find(|item| item.field == field)) + } + pub async fn get_fields<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::get_fields_per_loader(loader_ids, exec, redis) + .await? + .into_values() + .flatten() + .unique_by(|field| field.id) + .collect()) + } + pub async fn get_fields_per_loader<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Loader::list(exec, redis) + .await? + .into_iter() + .filter(|loader| loader_ids.contains(&loader.id)) + .map(|loader| { + let fields = minecraft_fields() + .into_iter() + .filter(|field| { + loader + .supported_games + .iter() + .any(|game| game == "minecraft-java") + && (field.field != "mrpack_loaders" + || loader.loader == "mrpack") + }) + .collect(); + (loader.id, fields) + }) + .collect()) + } + pub async fn get_fields_all<'a, E>( + _exec: E, + _redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(minecraft_fields()) + } } impl LoaderFieldEnum { - pub async fn get<'a, E>( - enum_name: &str, - _exec: E, - _redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(minecraft_fields() - .into_iter() - .find(|field| field.field == enum_name) - .map(|field| Self { - id: LoaderFieldEnumId(field.id.0), - enum_name: field.field, - ordering: Some(field.id.0), - hidable: field.id.0 == 1, - })) - } + pub async fn get<'a, E>( + enum_name: &str, + _exec: E, + _redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(minecraft_fields() + .into_iter() + .find(|field| field.field == enum_name) + .map(|field| Self { + id: LoaderFieldEnumId(field.id.0), + enum_name: field.field, + ordering: Some(field.id.0), + hidable: field.id.0 == 1, + })) + } } impl LoaderFieldEnumValue { - /// Fetches authoritative game-version metadata using an explicitly supplied service. - pub async fn minecraft_catalog( - db: &crate::database::MinecraftMetaDb, - ) -> Result> { - let values = { - let mut conn = - db.0.connection() - .await - .wrap_err("acquiring Minecraft metadata connection")?; - minecraft_meta_model::model::MinecraftVersion::all() - .exec(&mut conn) - .await - .wrap_err("fetching Minecraft version catalog")? - .into_iter() - .map(|row| { - let name = row.name.to_string(); - let mut value = compatibility_value( - 1, - super::legacy_loader_fields::legacy_game_version_name(&name).to_owned(), - ); - value.created = DateTime::::from_timestamp( - row.catalog_entry.0.release_time.as_second(), - row.catalog_entry.0.release_time.subsec_nanosecond() - as u32, - ) - .expect("Minecraft timestamp is representable by chrono"); - value.created = super::legacy_loader_fields::legacy_game_version_date(&name, value.created); - use minecraft_meta_model::upstream::mojang::VersionType; - value.ty = Some( - match row.catalog_entry.0.ty { - VersionType::Release => "release", - VersionType::Snapshot => "snapshot", - VersionType::OldAlpha => "alpha", - VersionType::OldBeta => "beta", - } - .to_owned(), - ); - value.major = Some(value.ty.as_deref() == Some("release")); - value - }) - .collect::>() - }; - let mut seen = std::collections::HashSet::new(); - eyre::ensure!( - values.iter().all(|value| seen.insert(value.id)), - "compatibility enum value ID collision" - ); - Ok(values) - } - - pub async fn list<'a, E>( - id: LoaderFieldEnumId, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list_many(&[id], exec, redis) - .await? - .remove(&id) - .unwrap_or_default()) - } - pub async fn list_many_loader_fields<'a, E>( - fields: &[LoaderField], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let ids = fields - .iter() - .filter_map(|field| match field.field_type { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - Some(id) - } - _ => None, - }) - .collect::>(); - let values = Self::list_many(&ids, exec, redis).await?; - Ok(fields - .iter() - .filter_map(|field| match field.field_type { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - Some((field.id, values[&id].clone())) - } - _ => None, - }) - .collect()) - } - pub async fn list_many<'a, E>( - ids: &[LoaderFieldEnumId], - _exec: E, - _redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - use crate::models::exp::minecraft::{Environment, MrpackLoader}; - let mut result = HashMap::new(); - for &id in ids { - let mut values = match id.0 { - 1 => { - Self::minecraft_catalog(minecraft_metadata().await?).await? - } - 2 => [ - Environment::ClientAndServer, - Environment::ClientOnly, - Environment::ClientOnlyServerOptional, - Environment::SingleplayerOnly, - Environment::ServerOnly, - Environment::ServerOnlyClientOptional, - Environment::DedicatedServerOnly, - Environment::ClientOrServer, - Environment::ClientOrServerPrefersBoth, - Environment::Unknown, - ] - .into_iter() - .map(|value| compatibility_value(2, value.as_str().to_owned())) - .collect(), - 3 => [ - MrpackLoader::Forge, - MrpackLoader::Fabric, - MrpackLoader::Quilt, - MrpackLoader::Modloader, - MrpackLoader::Rift, - MrpackLoader::Liteloader, - MrpackLoader::Minecraft, - MrpackLoader::Bukkit, - MrpackLoader::Spigot, - MrpackLoader::Paper, - MrpackLoader::Purpur, - MrpackLoader::Bungeecord, - MrpackLoader::Waterfall, - MrpackLoader::Velocity, - MrpackLoader::Sponge, - MrpackLoader::Optifine, - MrpackLoader::Iris, - MrpackLoader::Canvas, - MrpackLoader::Vanilla, - MrpackLoader::Datapack, - MrpackLoader::Folia, - MrpackLoader::Neoforge, - MrpackLoader::Babric, - MrpackLoader::BtaBabric, - MrpackLoader::JavaAgent, - MrpackLoader::LegacyFabric, - MrpackLoader::Nilloader, - MrpackLoader::Ornithe, - ] - .into_iter() - .map(|value| compatibility_value(3, value.as_str().to_owned())) - .collect(), - _ => eyre::bail!("unknown compatibility enum ID {}", id.0), - }; - values.sort_by(|a, b| { - b.created - .cmp(&a.created) - .then_with(|| a.value.cmp(&b.value)) - }); - let mut seen = std::collections::HashSet::new(); - eyre::ensure!( - values.iter().all(|value| seen.insert(value.id)), - "compatibility enum value ID collision" - ); - result.insert(id, values); - } - Ok(result) - } - pub async fn list_filter<'a, E>( - id: LoaderFieldEnumId, - filter: HashMap, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list(id, exec, redis) - .await? - .into_iter() - .filter(|value| { - filter.iter().all(|(key, expected)| match key.as_str() { - "type" => value - .ty - .as_deref() - .is_some_and(|ty| expected.as_str() == Some(ty)), - "major" => value - .major - .is_some_and(|major| expected.as_bool() == Some(major)), - _ => false, - }) - }) - .collect()) - } + /// Fetches authoritative game-version metadata using an explicitly supplied service. + pub async fn minecraft_catalog( + db: &crate::database::MinecraftMetaDb, + ) -> Result> { + let values = { + let mut conn = + db.0.connection() + .await + .wrap_err("acquiring Minecraft metadata connection")?; + minecraft_meta_model::model::MinecraftVersion::all() + .exec(&mut conn) + .await + .wrap_err("fetching Minecraft version catalog")? + .into_iter() + .map(|row| { + let name = row.name.to_string(); + let mut value = compatibility_value( + 1, + legacy_game_version_name(&name).to_owned(), + ); + value.created = DateTime::::from_timestamp( + row.catalog_entry.0.release_time.as_second(), + row.catalog_entry.0.release_time.subsec_nanosecond() + as u32, + ) + .expect("Minecraft timestamp is representable by chrono"); + value.created = + legacy_game_version_date(&name, value.created); + use minecraft_meta_model::upstream::mojang::VersionType; + value.ty = Some( + match row.catalog_entry.0.ty { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldAlpha => "alpha", + VersionType::OldBeta => "beta", + } + .to_owned(), + ); + value.major = Some(value.ty.as_deref() == Some("release")); + value + }) + .collect::>() + }; + let mut seen = std::collections::HashSet::new(); + eyre::ensure!( + values.iter().all(|value| seen.insert(value.id)), + "compatibility enum value ID collision" + ); + Ok(values) + } + + pub async fn list<'a, E>( + id: LoaderFieldEnumId, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list_many(&[id], exec, redis) + .await? + .remove(&id) + .unwrap_or_default()) + } + pub async fn list_many_loader_fields<'a, E>( + fields: &[LoaderField], + exec: E, + redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let ids = fields + .iter() + .filter_map(|field| match field.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + Some(id) + } + _ => None, + }) + .collect::>(); + let values = Self::list_many(&ids, exec, redis).await?; + Ok(fields + .iter() + .filter_map(|field| match field.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + Some((field.id, values[&id].clone())) + } + _ => None, + }) + .collect()) + } + pub async fn list_many<'a, E>( + ids: &[LoaderFieldEnumId], + _exec: E, + _redis: &RedisPool, + ) -> Result>> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + use crate::models::exp::minecraft::{Environment, MrpackLoader}; + use strum::IntoEnumIterator; + let mut result = HashMap::new(); + for &id in ids { + let mut values = match id.0 { + 1 => { + Self::minecraft_catalog(minecraft_metadata().await?).await? + } + 2 => Environment::iter() + .map(|value| { + compatibility_value(2, value.as_str().to_owned()) + }) + .collect(), + 3 => MrpackLoader::iter() + .map(|value| { + compatibility_value(3, value.as_str().to_owned()) + }) + .collect(), + _ => eyre::bail!("unknown compatibility enum ID {}", id.0), + }; + values.sort_by(|a, b| { + b.created + .cmp(&a.created) + .then_with(|| a.value.cmp(&b.value)) + }); + let mut seen = std::collections::HashSet::new(); + eyre::ensure!( + values.iter().all(|value| seen.insert(value.id)), + "compatibility enum value ID collision" + ); + result.insert(id, values); + } + Ok(result) + } + pub async fn list_filter<'a, E>( + id: LoaderFieldEnumId, + filter: HashMap, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list(id, exec, redis) + .await? + .into_iter() + .filter(|value| { + filter.iter().all(|(key, expected)| match key.as_str() { + "type" => value + .ty + .as_deref() + .is_some_and(|ty| expected.as_str() == Some(ty)), + "major" => value + .major + .is_some_and(|major| expected.as_bool() == Some(major)), + _ => false, + }) + }) + .collect()) + } } impl VersionField { - /// Hydrates game versions before exposing legacy dates to validation or webhooks. - pub async fn from_minecraft_component( - version_id: DBVersionId, - component: &crate::models::exp::minecraft::ModVersion, - ) -> Result> { - let catalog = LoaderFieldEnumValue::minecraft_catalog( - minecraft_metadata().await?, - ) - .await?; - Self::from_minecraft_component_with_catalog( - version_id, component, &catalog, - ) - } - - /// Reuses one catalog across a batch of versions without additional database reads. - pub fn from_minecraft_component_with_catalog( - version_id: DBVersionId, - component: &crate::models::exp::minecraft::ModVersion, - catalog: &[LoaderFieldEnumValue], - ) -> Result> { - let mut fields = vec![ - Self { - version_id, - field_id: LoaderFieldId(1), - field_name: "game_versions".into(), - value: VersionFieldValue::ArrayEnum( - LoaderFieldEnumId(1), - component - .game_versions - .iter() - .map(|name| { - catalog - .iter() - .find(|value| { - value.enum_id == LoaderFieldEnumId(1) - && value.value == name.0 - }) - .cloned() - .ok_or_else(|| { - eyre::eyre!( - "Minecraft version `{name}` is absent from the metadata catalog" - ) - }) - }) - .collect::>>()?, - ), - }, - Self { - version_id, - field_id: LoaderFieldId(3), - field_name: "mrpack_loaders".into(), - value: VersionFieldValue::ArrayEnum( - LoaderFieldEnumId(3), - component - .mrpack_loaders - .iter() - .map(|value| { - compatibility_value( - 3, - value.as_str().to_owned(), - ) - }) - .collect(), - ), - }, - ]; - if let Some(environment) = component.environment { - fields.push(Self { - version_id, - field_id: LoaderFieldId(2), - field_name: "environment".into(), - value: VersionFieldValue::Enum( - LoaderFieldEnumId(2), - compatibility_value( - 2, - environment.as_str().to_owned(), - ), - ), - }); - } - Ok(fields) - } - - pub async fn insert_many( - items: Vec, - transaction: &mut PgTransaction<'_>, - ) -> Result<()> { - let mut patches = HashMap::< - DBVersionId, - serde_json::Map, - >::new(); - for item in items { - let schema = minecraft_fields() - .into_iter() - .find(|field| { - field.field == item.field_name && field.id == item.field_id - }) - .ok_or_else(|| { - eyre::eyre!( - "unknown Minecraft compatibility field `{}`", - item.field_name - ) - })?; - let value = match (&schema.field_type, item.value) { - ( - LoaderFieldType::Enum(id), - VersionFieldValue::Enum(actual, value), + /// Converts compatibility field names and enum values directly into typed metadata. + pub fn to_minecraft_component( + fields: &[VersionField], + ) -> Result { + use minecraft_meta_model::model::MinecraftVersionName; + + let mut component = exp::minecraft::ModVersion { + game_versions: Vec::new(), + environment: None, + mrpack_loaders: Vec::new(), + }; + let mut seen = std::collections::HashSet::new(); + for field in fields { + eyre::ensure!( + seen.insert(field.field_name.as_str()), + "duplicate Minecraft compatibility field `{}`", + field.field_name + ); + match (field.field_name.as_str(), &field.value) { + ("game_versions", VersionFieldValue::ArrayEnum(_, values)) => { + component.game_versions = values + .iter() + .map(|value| MinecraftVersionName(value.value.clone())) + .collect(); + } + ("environment", VersionFieldValue::Enum(_, value)) => { + component.environment = Some( + value + .value + .parse() + .wrap_err("parsing Minecraft environment")?, + ); + } + ("mrpack_loaders", VersionFieldValue::ArrayEnum(_, values)) => { + component.mrpack_loaders = values + .iter() + .map(|value| { + value + .value + .parse() + .wrap_err("parsing Minecraft mrpack loader") + }) + .collect::>>()?; + } + ("game_versions" | "environment" | "mrpack_loaders", _) => { + eyre::bail!( + "invalid value type for Minecraft field `{}`", + field.field_name + ); + } + _ => eyre::bail!( + "unknown Minecraft compatibility field `{}`", + field.field_name + ), + } + } + Ok(component) + } + + /// Hydrates game versions before exposing legacy dates to validation or webhooks. + pub async fn from_minecraft_component( + version_id: DBVersionId, + component: &crate::models::exp::minecraft::ModVersion, + ) -> Result> { + let catalog = LoaderFieldEnumValue::minecraft_catalog( + minecraft_metadata().await?, + ) + .await?; + Self::from_minecraft_component_with_catalog( + version_id, component, &catalog, + ) + } + + /// Reuses one catalog across a batch of versions without additional database reads. + pub fn from_minecraft_component_with_catalog( + version_id: DBVersionId, + component: &crate::models::exp::minecraft::ModVersion, + catalog: &[LoaderFieldEnumValue], + ) -> Result> { + let mut fields = vec![ + Self { + version_id, + field_id: LoaderFieldId(1), + field_name: "game_versions".into(), + value: VersionFieldValue::ArrayEnum( + LoaderFieldEnumId(1), + component + .game_versions + .iter() + .map(|name| { + catalog + .iter() + .find(|value| { + value.enum_id == LoaderFieldEnumId(1) + && value.value == name.0 + }) + .cloned() + .ok_or_else(|| { + eyre::eyre!( + "Minecraft version `{name}` is absent from the metadata catalog" + ) + }) + }) + .collect::>>()?, + ), + }, + Self { + version_id, + field_id: LoaderFieldId(3), + field_name: "mrpack_loaders".into(), + value: VersionFieldValue::ArrayEnum( + LoaderFieldEnumId(3), + component + .mrpack_loaders + .iter() + .map(|value| { + compatibility_value(3, value.as_str().to_owned()) + }) + .collect(), + ), + }, + ]; + if let Some(environment) = component.environment { + fields.push(Self { + version_id, + field_id: LoaderFieldId(2), + field_name: "environment".into(), + value: VersionFieldValue::Enum( + LoaderFieldEnumId(2), + compatibility_value(2, environment.as_str().to_owned()), + ), + }); + } + Ok(fields) + } + + pub async fn insert_many( + items: Vec, + transaction: &mut PgTransaction<'_>, + ) -> Result<()> { + let mut patches = HashMap::< + DBVersionId, + serde_json::Map, + >::new(); + for item in items { + let schema = minecraft_fields() + .into_iter() + .find(|field| { + field.field == item.field_name && field.id == item.field_id + }) + .ok_or_else(|| { + eyre::eyre!( + "unknown Minecraft compatibility field `{}`", + item.field_name + ) + })?; + let value = match (&schema.field_type, item.value) { + ( + LoaderFieldType::Enum(id), + VersionFieldValue::Enum(actual, value), ) if *id == actual => { - let _: crate::models::exp::minecraft::Environment = value.value.parse()?; + let _: crate::models::exp::minecraft::Environment = + value.value.parse()?; serde_json::Value::String(value.value) - }, - ( - LoaderFieldType::ArrayEnum(id), - VersionFieldValue::ArrayEnum(actual, values), + } + ( + LoaderFieldType::ArrayEnum(id), + VersionFieldValue::ArrayEnum(actual, values), ) if *id == actual => { if item.field_name == "mrpack_loaders" { for value in &values { - let _: crate::models::exp::minecraft::MrpackLoader = value.value.parse()?; + let _: crate::models::exp::minecraft::MrpackLoader = + value.value.parse()?; } } - serde_json::Value::Array(values.into_iter() - .map(|value| serde_json::Value::String(value.value)) - .collect()) - }, - _ => eyre::bail!( - "invalid value type for Minecraft field `{}`", - item.field_name - ), - }; - - patches - .entry(item.version_id) - .or_default() - .insert(item.field_name, value); - } - for (version_id, patch) in patches { - let patch = serde_json::Value::Object(patch); - let result = sqlx::query!( - r#" - UPDATE versions SET components = jsonb_set( - COALESCE(components, '{}'::jsonb), '{minecraft_mod}', - jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb) - || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb) - || $2::jsonb - ) WHERE id = $1 - "#, - version_id.0, - patch - ) - .execute(&mut *transaction) - .await - .wrap_err("updating Minecraft version component")?; - eyre::ensure!( - result.rows_affected() == 1, - "version {} does not exist", - version_id.0 - ); - } - Ok(()) - } + serde_json::Value::Array( + values + .into_iter() + .map(|value| serde_json::Value::String(value.value)) + .collect(), + ) + } + _ => eyre::bail!( + "invalid value type for Minecraft field `{}`", + item.field_name + ), + }; + + patches + .entry(item.version_id) + .or_default() + .insert(item.field_name, value); + } + for (version_id, patch) in patches { + let patch = serde_json::Value::Object(patch); + let result = sqlx::query!( + r#" + UPDATE versions SET components = jsonb_set( + COALESCE(components, '{}'::jsonb), '{minecraft_mod}', + jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb) + || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb) + || $2::jsonb + ) WHERE id = $1 + "#, + version_id.0, + patch + ) + .execute(&mut *transaction) + .await + .wrap_err("updating Minecraft version component")?; + eyre::ensure!( + result.rows_affected() == 1, + "version {} does not exist", + version_id.0 + ); + } + Ok(()) + } pub fn check_parse( version_id: DBVersionId, @@ -1510,3 +1369,169 @@ impl VersionFieldValue { } } } + +/// Mojang version names that require aliases for historical Labrinth compatibility. +// A list of version names that contains spaces. +// Generated using the command +// ```sh +// curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ +// | jq '[.versions[].id | select(contains(" "))]' +// ``` +pub(crate) const HALL_OF_SHAME: [(&str, &str); 12] = [ + ("1.14.2 Pre-Release 4", "1.14.2-pre4"), + ("1.14.2 Pre-Release 3", "1.14.2-pre3"), + ("1.14.2 Pre-Release 2", "1.14.2-pre2"), + ("1.14.2 Pre-Release 1", "1.14.2-pre1"), + ("1.14.1 Pre-Release 2", "1.14.1-pre2"), + ("1.14.1 Pre-Release 1", "1.14.1-pre1"), + ("1.14 Pre-Release 5", "1.14-pre5"), + ("1.14 Pre-Release 4", "1.14-pre4"), + ("1.14 Pre-Release 3", "1.14-pre3"), + ("1.14 Pre-Release 2", "1.14-pre2"), + ("1.14 Pre-Release 1", "1.14-pre1"), + ("3D Shareware v1.34", "3D-Shareware-v1.34"), +]; + +/// Preserves historical Labrinth aliases while leaving other catalog names unchanged. +pub(crate) fn legacy_game_version_name(name: &str) -> &str { + HALL_OF_SHAME + .iter() + .find_map(|(canonical, legacy)| (*canonical == name).then_some(*legacy)) + .unwrap_or(name) +} + +/// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, +/// as most of our ordering logic is with DateTime +pub(crate) static HALL_OF_SHAME_2: LazyLock<[(&str, DateTime); 4]> = + LazyLock::new(|| { + [ + ("1.4.5", "2012-12-19T22:00:00+00:00"), + ("1.4.6", "2012-12-19T22:00:01+00:00"), + ("1.6.3", "2013-09-13T10:54:41+00:00"), + ("13w37b", "2013-09-13T10:54:42+00:00"), + ] + .map(|(version, date)| { + ( + version, + DateTime::parse_from_rfc3339(date) + .unwrap() + .with_timezone(&Utc), + ) + }) + }); + +/// Preserves the historical ordering of releases with identical upstream timestamps. +pub(crate) fn legacy_game_version_date( + name: &str, + date: DateTime, +) -> DateTime { + HALL_OF_SHAME_2 + .iter() + .find_map(|(version, corrected)| { + (*version == name).then_some(*corrected) + }) + .unwrap_or(date) +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct MinecraftGameVersion { + pub id: LoaderFieldEnumValueId, + pub version: String, + #[serde(rename = "type")] + pub type_: String, + pub created: DateTime, + pub major: bool, +} + +impl MinecraftGameVersion { + // The name under which this legacy field is stored as a LoaderField + pub const FIELD_NAME: &'static str = "game_versions"; + + pub async fn list<'a, E>( + version_type_option: Option<&str>, + major_option: Option, + exec: E, + redis: &RedisPool, + ) -> Result> + where + E: crate::database::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = exec + .acquire() + .await + .wrap_err("acquiring database connection")?; + let game_version_enum = + LoaderFieldEnum::get(Self::FIELD_NAME, &mut exec, redis) + .await + .wrap_err("fetching game version enum")? + .ok_or_else(|| eyre::eyre!("Could not find game version enum.")) + .wrap_err("finding game version enum")?; + let game_version_enum_values = + LoaderFieldEnumValue::list(game_version_enum.id, &mut exec, redis) + .await + .wrap_err("fetching game version enum values")?; + + let game_versions = game_version_enum_values + .into_iter() + .map(MinecraftGameVersion::from_enum_value) + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = version_type_option { + bool &= &*x.type_ == version_type; + } + if let Some(major) = major_option { + bool &= x.major == major; + } + + bool + }) + .collect_vec(); + + Ok(game_versions) + } + + // Tries to create a MinecraftGameVersion from a VersionField + // Clones on success + pub fn try_from_version_field( + version_field: &VersionField, + ) -> Result> { + if version_field.field_name != Self::FIELD_NAME { + return Err(eyre::eyre!( + "Field name {} is not {}", + version_field.field_name, + Self::FIELD_NAME + )); + } + let game_versions = match version_field.clone() { + VersionField { + value: VersionFieldValue::ArrayEnum(_, values), + .. + } => values.into_iter().map(Self::from_enum_value).collect(), + VersionField { + value: VersionFieldValue::Enum(_, value), + .. + } => { + vec![Self::from_enum_value(value)] + } + _ => { + return Err(eyre::eyre!( + "Game version requires field value to be an enum: {version_field:?}" + )); + } + }; + Ok(game_versions) + } + + pub fn from_enum_value( + loader_field_enum_value: LoaderFieldEnumValue, + ) -> MinecraftGameVersion { + MinecraftGameVersion { + id: loader_field_enum_value.id, + version: loader_field_enum_value.value, + created: loader_field_enum_value.created, + type_: loader_field_enum_value.ty.unwrap_or_default(), + major: loader_field_enum_value.major.unwrap_or_default(), + } + } +} diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index c38b17aeb5..28f19c09c8 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod error; pub mod exp; pub mod link_platform; +pub mod minecraft; pub mod v2; pub mod v3; diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs index 80e81323ab..ac11246a76 100644 --- a/apps/labrinth/src/models/v2/projects.rs +++ b/apps/labrinth/src/models/v2/projects.rs @@ -1,7 +1,5 @@ use std::convert::TryFrom; -use std::collections::HashMap; - use super::super::ids::OrganizationId; use crate::database::models::{DBProjectDisclosure, DBProjectId, version_item}; use crate::models::disclosures::ProjectDisclosureType; @@ -142,27 +140,15 @@ impl LegacyProject { .collect(); if let Some(versions_item) = versions_item { - if let Some(component) = versions_item.inner.components.minecraft_mod.as_ref() { - (client_side, server_side) = - v2_reroute::convert_minecraft_environment_to_v2_side_types( - component.environment.unwrap_or(Environment::Unknown), - Some(&og_project_type), - ); - } else { - // Extract side types from remaining fields - let fields = versions_item - .version_fields - .iter() - .map(|f| { - (f.field_name.clone(), f.value.clone().serialize_internal()) - }) - .collect::>(); - (client_side, server_side) = - v2_reroute::convert_v3_side_types_to_v2_side_types( - &fields, - Some(&*og_project_type), - ); - } + if let Some(component) = + versions_item.inner.components.minecraft_mod.as_ref() + { + (client_side, server_side) = + v2_reroute::convert_minecraft_environment_to_v2_side_types( + component.environment.unwrap_or(Environment::Unknown), + Some(&og_project_type), + ); + } // - if loader is mrpack, this is a modpack // the loaders are whatever the corresponding loader fields are @@ -366,46 +352,39 @@ pub struct LegacyVersion { impl From for LegacyVersion { fn from(data: Version) -> Self { - let mut game_versions = Vec::new(); - if let Some(value) = - data.fields.get("game_versions").and_then(|v| v.as_array()) - { - for gv in value { - if let Some(game_version) = gv.as_str() { - game_versions.push(game_version.to_string()); - } - } - } - let minecraft = data.components.minecraft_mod.as_ref(); - if let Some(component) = minecraft { - game_versions = component.game_versions.iter().map(ToString::to_string).collect(); - } - let environment = if let Some(component) = minecraft { - component.environment.unwrap_or(Environment::Unknown).as_str().to_string() - } else { - data.fields - .get("environment") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - .to_string() - }; + let minecraft = data.components.minecraft_mod.as_ref(); + let game_versions = minecraft + .map(|component| { + component + .game_versions + .iter() + .map(|name| { + crate::models::minecraft::legacy_game_version_name( + &name.0, + ) + .to_owned() + }) + .collect() + }) + .unwrap_or_default(); + let environment = minecraft + .and_then(|component| component.environment) + .unwrap_or(Environment::Unknown) + .as_str() + .to_owned(); // - if loader is mrpack, this is a modpack // the v2 loaders are whatever the corresponding loader fields are let mut loaders = data.loaders.into_iter().map(|l| l.0).collect::>(); - if loaders.contains(&"mrpack".to_string()) && let Some(component) = minecraft { - loaders = component.mrpack_loaders.iter() + if loaders.contains(&"mrpack".to_string()) + && let Some(component) = minecraft + { + loaders = component + .mrpack_loaders + .iter() .map(|loader| loader.as_str().to_string()) .collect(); - } else if loaders.contains(&"mrpack".to_string()) - && let Some((_, mrpack_loaders)) = data - .fields - .into_iter() - .find(|(key, _)| key == "mrpack_loaders") - && let Ok(mrpack_loaders) = serde_json::from_value(mrpack_loaders) - { - loaders = mrpack_loaders; } let loaders = loaders.into_iter().map(Loader).collect::>(); diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index ffef92eedc..f5f915d4f6 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -1,7 +1,7 @@ +use crate::models::minecraft::VersionField; use std::collections::HashMap; use std::mem; -use crate::database::models::loader_fields::VersionField; use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; use crate::models::exp; @@ -9,6 +9,7 @@ use crate::models::ids::{ FileId, GalleryImageId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, }; +use crate::models::minecraft::legacy_game_version_name; use crate::routes::{FileHash, HashAlgorithm}; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; @@ -139,10 +140,62 @@ pub fn from_duplicate_version_fields( fields } +/// Projects typed Minecraft metadata into the flattened compatibility fields. +fn minecraft_version_fields( + component: &exp::minecraft::ModVersion, + mrpack: bool, +) -> HashMap { + let mut fields = HashMap::from([( + "game_versions".to_owned(), + serde_json::Value::Array( + component + .game_versions + .iter() + .map(|name| { + serde_json::Value::String( + legacy_game_version_name(&name.0).to_owned(), + ) + }) + .collect(), + ), + )]); + if let Some(environment) = component.environment { + fields.insert("environment".to_owned(), environment.as_str().into()); + } + if mrpack { + fields.insert( + "mrpack_loaders".to_owned(), + serde_json::Value::Array( + component + .mrpack_loaders + .iter() + .map(|loader| loader.as_str().into()) + .collect(), + ), + ); + } + fields +} + impl From for Project { fn from(data: ProjectQueryResult) -> Self { - let fields = - from_duplicate_version_fields(data.aggregate_version_fields); + let mut fields: HashMap> = + HashMap::new(); + let mrpack = data.inner.loaders.iter().any(|loader| loader == "mrpack"); + for component in &data.minecraft_mod_versions { + for (name, value) in minecraft_version_fields(component, mrpack) { + let values = match value { + serde_json::Value::Array(values) => values, + value => vec![value], + }; + let aggregate = fields.entry(name).or_default(); + for value in values { + if !aggregate.contains(&value) { + aggregate.push(value); + } + } + } + } let m = data.inner; Self { id: m.id.into(), @@ -826,6 +879,18 @@ where impl From for Version { fn from(data: VersionQueryResult) -> Version { + let fields = data + .inner + .components + .minecraft_mod + .as_ref() + .map(|component| { + minecraft_version_fields( + component, + data.loaders.iter().any(|loader| loader == "mrpack"), + ) + }) + .unwrap_or_default(); let v = data.inner; Version { id: v.id.into(), @@ -876,13 +941,7 @@ impl From for Version { }) .collect(), loaders: data.loaders.into_iter().map(Loader).collect(), - // Only add the internal component of the field for display - // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) - fields: data - .version_fields - .into_iter() - .map(|vf| (vf.field_name, vf.value.serialize_internal())) - .collect(), + fields, components: data.components, files_missing_attribution: Vec::new(), } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index dfd16efd26..5e0622c585 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -2311,7 +2311,7 @@ pub async fn stripe_webhook( ) } else { // Create a server with the latest version of Minecraft - let minecraft_versions = crate::database::models::legacy_loader_fields::MinecraftGameVersion::list( + let minecraft_versions = crate::models::minecraft::MinecraftGameVersion::list( Some("release"), None, &**pool, diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 040681f5b6..24e7360714 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -1,12 +1,12 @@ use crate::auth::checks::{is_visible_project, is_visible_version}; -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; -use crate::database::models::loader_fields::Loader; +use crate::database::models::loader_item::Loader; use crate::database::models::project_item::ProjectQueryResult; use crate::database::models::version_item::{ FileQueryResult, VersionQueryResult, }; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::ids::{ProjectId, VersionId}; +use crate::models::minecraft::legacy_game_version_name; use crate::models::pats::Scopes; use crate::models::projects::FileType; use crate::queue::session::AuthQueue; @@ -318,15 +318,15 @@ async fn find_version( // For maven in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive if !game_versions.is_empty() { - let version_game_versions = - x.version_fields.clone().into_iter().find_map(|v| { - MinecraftGameVersion::try_from_version_field(&v).ok() - }); - if let Some(version_game_versions) = version_game_versions { - bool &= version_game_versions - .iter() - .any(|y| game_versions.contains(&y.version)); - } + bool &= x.inner.components.minecraft_mod.as_ref().is_some_and( + |component| { + component.game_versions.iter().any(|name| { + game_versions.iter().any(|requested| { + requested == legacy_game_version_name(&name.0) + }) + }) + }, + ); } bool diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs index 55103a879c..5ef0222c9a 100644 --- a/apps/labrinth/src/routes/updates.rs +++ b/apps/labrinth/src/routes/updates.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::auth::checks::{filter_visible_versions, is_visible_project}; use crate::auth::get_user_from_headers; use crate::database; -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::models::minecraft::legacy_game_version_name; use crate::models::pats::Scopes; use crate::models::projects::VersionType; use crate::queue::session::AuthQueue; @@ -122,12 +122,16 @@ pub async fn forge_updates( // For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive // Will have duplicates between game_versions (for non-forge loaders), but that's okay as // before v3 this was stored to the project and not the version - let game_versions: Vec = version - .fields - .iter() - .find(|(key, _)| key.as_str() == MinecraftGameVersion::FIELD_NAME) - .and_then(|(_, value)| { - serde_json::from_value::>(value.clone()).ok() + let game_versions = version + .components + .minecraft_mod + .as_ref() + .map(|component| { + component + .game_versions + .iter() + .map(|name| legacy_game_version_name(&name.0).to_owned()) + .collect::>() }) .unwrap_or_default(); diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 855f9961f6..87cf30e658 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -1,7 +1,7 @@ use crate::util::error::{ApiContext as _, Context as _}; use super::ApiError; -use crate::database::models::legacy_loader_fields::{ +use crate::models::minecraft::{ legacy_game_version_date, legacy_game_version_name, }; use crate::database::{MinecraftMetaDb, PgPool}; diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 4ef98de228..d78a918adf 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -405,10 +405,16 @@ pub async fn versions_get( .any(|loader| loader == "mrpack") { version - .version_fields - .iter() - .find(|field| field.field_name == "mrpack_loaders") - .map(|field| field.value.as_strings()) + .components + .minecraft_mod + .as_ref() + .map(|component| { + component + .mrpack_loaders + .iter() + .map(|loader| loader.as_str().to_owned()) + .collect() + }) .unwrap_or_else(|| version.loaders.clone()) } else { version.loaders.clone() diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index b595de873a..fa5a4d7d98 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -4,9 +4,7 @@ use crate::auth::{ }; use crate::database::PgPool; use crate::database::PgTransaction; -use crate::database::models::loader_fields::{ - Loader, LoaderField, LoaderFieldEnumValue, -}; +use crate::database::models::loader_item::Loader; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, DBUser, image_item}; use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError}; @@ -15,6 +13,9 @@ use crate::models::exp; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::link_platform::LinkPlatform; +use crate::models::minecraft::{ + LoaderField, LoaderFieldEnumValue, VersionField, +}; use crate::models::pats::Scopes; use crate::models::projects::{ License, Link, MonetizationStatus, Project, ProjectStatus, @@ -554,7 +555,7 @@ async fn project_create_inner( } let all_loaders = - models::loader_fields::Loader::list(&mut *transaction, redis).await?; + models::loader_item::Loader::list(&mut *transaction, redis).await?; let project_create_data: ProjectCreateData; let mut versions; @@ -818,6 +819,11 @@ async fn project_create_inner( .iter() .map(|x| x.filename.clone()) .collect(); + let version_fields = if let Some(component) = &created_version.components.minecraft_mod { + VersionField::from_minecraft_component(created_version.version_id, component).await? + } else { + Vec::new() + }; // Upload the new jar file super::version_creation::upload_file( &mut field, @@ -829,7 +835,7 @@ async fn project_create_inner( &content_disposition, project_id, created_version.version_id.into(), - &created_version.version_fields, + &version_fields, version_data.loaders.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), @@ -977,7 +983,7 @@ async fn project_create_inner( }) } - let mut project_builder_actual = models::project_item::ProjectBuilder { + let project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), team_id, organization_id: project_create_data @@ -1016,11 +1022,6 @@ async fn project_create_inner( components: exp::ProjectSerial::default(), }; let project_builder = project_builder_actual.clone(); - for version in &mut project_builder_actual.initial_versions { - if version.components.minecraft_mod.is_some() { - version.version_fields.clear(); - } - } let now = Utc::now(); @@ -1155,7 +1156,7 @@ async fn create_initial_version( version_data: &InitialVersionData, project_id: ProjectId, author: UserId, - all_loaders: &[models::loader_fields::Loader], + all_loaders: &[models::loader_item::Loader], transaction: &mut PgTransaction<'_>, redis: &RedisPool, ) -> Result { @@ -1224,14 +1225,19 @@ async fn create_initial_version( files: Vec::new(), dependencies, loaders, - version_fields, featured: version_data.featured, status: VersionStatus::Listed, version_type: version_data.release_channel.to_string(), requested_status: None, ordering: version_data.ordering, components: exp::VersionSerial { - minecraft_mod: version_data.minecraft_mod.clone(), + minecraft_mod: Some(match &version_data.minecraft_mod { + Some(component) => component.clone(), + None => VersionField::to_minecraft_component(&version_fields) + .map_err(|error| { + CreateError::InvalidInput(error.to_string()) + })?, + }), ..Default::default() }, }; diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index b26229edbb..6d6d3f426d 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -309,7 +309,6 @@ pub async fn create( files: Vec::new(), dependencies: Vec::new(), loaders: Vec::new(), - version_fields: Vec::new(), version_type: VersionType::Release.to_string(), featured: false, status: VersionStatus::Listed, diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index a0d9764b88..e4826e8122 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -3,17 +3,17 @@ use std::collections::HashMap; use super::ApiError; use crate::database::models::categories::{Category, ProjectType, ReportType}; -use crate::database::models::loader_fields::{ - Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, - LoaderMetadata, -}; +use crate::database::models::game_item::Game; +use crate::database::models::loader_item::{Loader, LoaderMetadata}; +use crate::models::exp::minecraft::{Environment, MrpackLoader}; use crate::models::link_platform::LinkPlatform; +use crate::models::minecraft::{LoaderFieldEnumValue, compatibility_value}; use actix_web::{HttpResponse, get, web}; use strum::IntoEnumIterator; use xredis::RedisPool; -use crate::database::PgPool; -use itertools::Itertools; +use crate::database::{MinecraftMetaDb, PgPool}; + use serde_json::Value; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -127,26 +127,31 @@ pub async fn loader_list( .await .wrap_internal_err("fetching loader from Redis")?; - let loader_fields = LoaderField::get_fields_per_loader( - &loaders.iter().map(|x| x.id).collect_vec(), - &**pool, - &redis, - ) - .await - .wrap_internal_err("fetching loader field from Redis")?; - let mut results = loaders .into_iter() - .map(|x| LoaderData { - icon: x.icon, - name: x.loader, - supported_project_types: x.supported_project_types, - supported_games: x.supported_games, - supported_fields: loader_fields - .get(&x.id) - .map(|x| x.iter().map(|x| x.field.clone()).collect_vec()) - .unwrap_or_default(), - metadata: x.metadata, + .map(|loader| { + let mut supported_fields = Vec::new(); + if loader + .supported_games + .iter() + .any(|game| game == "minecraft-java") + { + supported_fields.extend([ + "game_versions".to_owned(), + "environment".to_owned(), + ]); + if loader.loader == "mrpack" { + supported_fields.push("mrpack_loaders".to_owned()); + } + } + LoaderData { + icon: loader.icon, + name: loader.loader, + supported_project_types: loader.supported_project_types, + supported_games: loader.supported_games, + supported_fields, + metadata: loader.metadata, + } }) .collect::>(); @@ -172,53 +177,56 @@ pub struct LoaderFieldsEnumQuery { )] #[get("/loader_field")] pub async fn loader_fields_list_route( - pool: web::Data, + minecraft_meta: web::Data, web::Query(query): web::Query, - redis: web::Data, ) -> Result { - loader_fields_list(pool, web::Query(query), redis).await + loader_fields_list(minecraft_meta, web::Query(query)).await } pub async fn loader_fields_list( - pool: web::Data, + minecraft_meta: web::Data, query: web::Query, - redis: web::Data, ) -> Result { let query = query.into_inner(); - let loader_field = LoaderField::get_fields_all(&**pool, &redis) - .await - .wrap_internal_err("fetching loader field from Redis")? - .into_iter() - .find(|x| x.field == query.loader_field) - .wrap_request_err_with(|| { - format!("'{}' was not a valid loader field.", query.loader_field) - })?; - - let (LoaderFieldType::Enum(loader_field_enum_id) - | LoaderFieldType::ArrayEnum(loader_field_enum_id)) = - loader_field.field_type - else { - return Err(ApiError::Request(eyre::eyre!(format!( - "'{}' is not an enumerable field, but an '{}' field.", - query.loader_field, - loader_field.field_type.to_str() - )))); + let mut results = match query.loader_field.as_str() { + "game_versions" => { + LoaderFieldEnumValue::minecraft_catalog(&minecraft_meta) + .await + .wrap_internal_err("fetching Minecraft version catalog")? + } + "environment" => Environment::iter() + .map(|value| compatibility_value(2, value.as_str().to_owned())) + .collect(), + "mrpack_loaders" => MrpackLoader::iter() + .map(|value| compatibility_value(3, value.as_str().to_owned())) + .collect(), + _ => { + return Err(ApiError::Request(eyre::eyre!( + "`{}` is not a valid loader field", + query.loader_field + ))); + } }; - let results: Vec<_> = if let Some(filters) = query.filters { - LoaderFieldEnumValue::list_filter( - loader_field_enum_id, - filters, - &**pool, - &redis, - ) - .await - .wrap_internal_err("fetching loader field enum value from Redis")? - } else { - LoaderFieldEnumValue::list(loader_field_enum_id, &**pool, &redis) - .await - .wrap_internal_err("fetching loader field enum value from Redis")? - }; + results.sort_by(|a, b| { + b.created + .cmp(&a.created) + .then_with(|| a.value.cmp(&b.value)) + }); + if let Some(filters) = query.filters { + results.retain(|value| { + filters.iter().all(|(key, expected)| match key.as_str() { + "type" => value + .ty + .as_deref() + .is_some_and(|ty| expected.as_str() == Some(ty)), + "major" => value + .major + .is_some_and(|major| expected.as_bool() == Some(major)), + _ => false, + }) + }); + } Ok(HttpResponse::Ok().json(results)) } diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index f61002dae9..853314d834 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -2,9 +2,6 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; use crate::database::PgPool; use crate::database::PgTransaction; -use crate::database::models::loader_fields::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, @@ -15,6 +12,9 @@ use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::exp; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; +use crate::models::minecraft::{ + LoaderField, LoaderFieldEnumValue, VersionField, +}; use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; @@ -360,7 +360,7 @@ async fn version_create_inner( let version_id: VersionId = models::generate_version_id(transaction).await?.into(); let all_loaders = - models::loader_fields::Loader::list(&mut *transaction, redis).await?; + models::loader_item::Loader::list(&mut *transaction, redis).await?; let loaders = version_create_data .loaders .iter() @@ -412,14 +412,17 @@ async fn version_create_inner( files: Vec::new(), dependencies, loaders: loader_ids, - version_fields, version_type: version_create_data.release_channel.to_string(), featured: version_create_data.featured, status: version_create_data.status, requested_status: None, ordering: version_create_data.ordering, components: exp::VersionSerial { - minecraft_mod: version_create_data.minecraft_mod.clone(), + minecraft_mod: Some(match &version_create_data.minecraft_mod { + Some(component) => component.clone(), + None => VersionField::to_minecraft_component(&version_fields) + .map_err(|error| CreateError::InvalidInput(error.to_string()))?, + }), ..Default::default() }, }); @@ -444,6 +447,12 @@ async fn version_create_inner( let existing_file_names = version.files.iter().map(|x| x.filename.clone()).collect(); + let version_fields = if let Some(component) = &version.components.minecraft_mod { + VersionField::from_minecraft_component(version.version_id, component).await? + } else { + Vec::new() + }; + upload_file( &mut field, file_host, @@ -454,7 +463,7 @@ async fn version_create_inner( &content_disposition, version.project_id.into(), version.version_id.into(), - &version.version_fields, + &version_fields, loaders, version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), @@ -481,7 +490,7 @@ async fn version_create_inner( let version_data = initial_version_data.ok_or_else(|| { CreateError::InvalidInput("`data` field is required".to_string()) })?; - let mut builder = version_builder.ok_or_else(|| { + let builder = version_builder.ok_or_else(|| { CreateError::InvalidInput("`data` field is required".to_string()) })?; @@ -579,9 +588,6 @@ async fn version_create_inner( }; let project_id = builder.project_id; - if builder.components.minecraft_mod.is_some() { - builder.version_fields.clear(); - } builder .insert(transaction, redis, file_host, delphi_scan_files) .await?; @@ -782,7 +788,7 @@ async fn upload_file_to_version_inner( }; let all_loaders = - models::loader_fields::Loader::list(&mut *transaction, &redis).await?; + models::loader_item::Loader::list(&mut *transaction, &redis).await?; let selected_loaders = version .loaders .iter() @@ -903,6 +909,17 @@ async fn upload_file_to_version_inner( }) .collect(); + let version_fields = + if let Some(component) = &version.components.minecraft_mod { + VersionField::from_minecraft_component( + version_id, + component, + ) + .await? + } else { + Vec::new() + }; + upload_file( &mut field, file_host, @@ -913,7 +930,7 @@ async fn upload_file_to_version_inner( &content_disposition, project_id, version_id.into(), - &version.version_fields, + &version_fields, loaders, true, false, @@ -1212,7 +1229,7 @@ pub fn try_create_version_fields( minecraft_mod: Option<&crate::models::exp::minecraft::ModVersion>, loader_fields: &[LoaderField], loader_field_enum_values: &mut HashMap< - models::LoaderFieldId, + crate::models::minecraft::LoaderFieldId, Vec, >, ) -> Result, CreateError> { @@ -1233,8 +1250,12 @@ pub fn try_create_version_fields( ] { if let Some(schema) = loader_fields.iter().find(|field| field.field == name) - && (schema.min_val.is_some_and(|min| (count as i64) < i64::from(min)) - || schema.max_val.is_some_and(|max| (count as i64) > i64::from(max))) + && (schema + .min_val + .is_some_and(|min| (count as i64) < i64::from(min)) + || schema + .max_val + .is_some_and(|max| (count as i64) > i64::from(max))) { return Err(CreateError::InvalidInput(format!( "invalid number of values for `{name}`" @@ -1266,7 +1287,8 @@ pub fn try_create_version_fields( .iter() .filter(|lf| { !(lf.optional - || minecraft_mod.is_some() && typed_names.contains(&lf.field.as_str())) + || minecraft_mod.is_some() + && typed_names.contains(&lf.field.as_str())) }) .map(|lf| lf.field.clone()) .collect::>(); diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 8dba21adc4..7ae8775a80 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -7,9 +7,6 @@ use crate::auth::checks::{ }; use crate::auth::get_user_from_headers; use crate::database; -use crate::database::models::loader_fields::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; use crate::database::models::version_item::{ DBLoaderVersion, DependencyBuilder, }; @@ -18,6 +15,9 @@ use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models; use crate::models::ids::VersionId; use crate::models::images::ImageContext; +use crate::models::minecraft::{ + LoaderField, LoaderFieldEnumValue, VersionField, +}; use crate::models::pats::Scopes; use crate::models::projects::{ Dependency, FileType, VersionStatus, VersionType, @@ -611,7 +611,7 @@ pub(crate) async fn version_edit_in_transaction( let mut loader_ids = Vec::with_capacity(loaders.len()); for loader in loaders { let loader_id = - database::models::loader_fields::Loader::get_id( + database::models::loader_item::Loader::get_id( &loader.0, &mut *transaction, &redis, @@ -1157,8 +1157,8 @@ pub(crate) async fn auto_featured_filters( redis: &RedisPool, ) -> Result, ApiError> { let (loaders, game_versions) = futures::future::try_join( - database::models::loader_fields::Loader::list(&**ro_pool, redis), - database::models::legacy_loader_fields::MinecraftGameVersion::list( + database::models::loader_item::Loader::list(&**ro_pool, redis), + crate::models::minecraft::MinecraftGameVersion::list( None, Some(true), &**ro_pool, @@ -1206,13 +1206,38 @@ pub(crate) async fn filter_project_versions( } if let Some(loader_fields) = &loader_field_filters { for (key, values) in loader_fields { - bool &= if let Some(x_vf) = - x.version_fields.iter().find(|y| y.field_name == *key) - { - values.iter().any(|v| x_vf.value.contains_json_value(v)) - } else { - true - }; + bool &= x + .inner + .components + .minecraft_mod + .as_ref() + .is_none_or(|component| match key.as_str() { + "game_versions" => values.iter().any(|value| { + value.as_str().is_some_and(|value| { + component + .game_versions + .iter() + .any(|version| version.0 == value) + }) + }), + "environment" => component.environment.is_none_or( + |environment| { + values.iter().any(|value| { + value.as_str() + == Some(environment.as_str()) + }) + }, + ), + "mrpack_loaders" => values.iter().any(|value| { + value.as_str().is_some_and(|value| { + component + .mrpack_loaders + .iter() + .any(|loader| loader.as_str() == value) + }) + }), + _ => true, + }); } } bool @@ -1256,16 +1281,14 @@ pub(crate) async fn filter_project_versions( joined_filters.iter().for_each(|filter| { if let Some(version) = versions.iter().find(|version| { - // TODO: This is the bandaid fix for detecting auto-featured versions. - let game_versions = version - .version_fields - .iter() - .find(|vf| vf.field_name == "game_versions") - .map(|vf| vf.value.clone()) - .map(|v| v.as_strings()) - .unwrap_or_default(); - game_versions.contains(&filter.0) - && version.loaders.contains(&filter.1) + version.inner.components.minecraft_mod.as_ref().is_some_and( + |component| { + component + .game_versions + .iter() + .any(|version| version.0 == filter.0) + }, + ) && version.loaders.contains(&filter.1) }) { response.push(version.clone()); } diff --git a/apps/labrinth/src/search/indexing.rs b/apps/labrinth/src/search/indexing.rs index 72a680ad70..546f686031 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -10,14 +10,14 @@ use std::sync::LazyLock; use tracing::{info, warn}; use crate::database::PgPool; -use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField}; + use crate::database::models::{ - DBOrganizationId, DBProjectId, DBUserId, DBVersionId, LoaderFieldEnumId, + DBOrganizationId, DBProjectId, DBUserId, DBVersionId, }; use crate::models::disclosures::ProjectDisclosure; use crate::models::exp; use crate::models::ids::{ProjectId, VersionId}; -use crate::models::projects::{DependencyType, from_duplicate_version_fields}; +use crate::models::projects::DependencyType; use crate::models::v2::projects::LegacyProject; use crate::routes::v2_reroute; use crate::search::{ @@ -352,7 +352,7 @@ async fn build_search_documents( .wrap_err("failed to fetch project disclosures")?; info!("Indexing local versions!"); - let mut versions = load_project_versions(pool, redis, project_ids.clone()).await?; + let mut versions = load_project_versions(pool, project_ids.clone()).await?; info!("Indexing local org owners!"); @@ -580,26 +580,13 @@ async fn build_search_documents( .then_with(|| a.id.0.cmp(&b.id.0)) }); - let aggregated_version_fields = versions - .iter() - .flat_map(|version| version.version_fields.clone()) - .collect::>(); - let mut loader_fields = - from_duplicate_version_fields(aggregated_version_fields.clone()); - let mut unvectorized_loader_fields = loader_fields - .iter() - .map(|(name, values)| (name.clone(), serde_json::json!(values))) - .collect::>(); - if let Some(environment) = aggregated_version_fields - .iter() - .rev() - .find(|field| field.field_name == "environment") - { - unvectorized_loader_fields.insert( - "environment".to_owned(), - environment.value.serialize_internal(), - ); - } + let mut loader_fields = + minecraft_search_fields(versions.iter().filter_map(|version| { + version.components.minecraft_mod.as_ref() + })); + let project_environment = versions.iter().rev().find_map(|version| { + version.components.minecraft_mod.as_ref()?.environment + }); let project_loader_fields = loader_fields.clone(); let mut project_loaders = versions @@ -628,14 +615,8 @@ async fn build_search_documents( return None; } - let version_fields = version.version_fields.clone(); - let unvectorized_loader_fields = version_fields - .iter() - .map(|field| { - (field.field_name.clone(), field.value.serialize_internal()) - }) - .collect(); - let mut fields = from_duplicate_version_fields(version_fields); + let minecraft = version.components.minecraft_mod.as_ref(); + let mut fields = minecraft_search_fields(minecraft.into_iter()); let mut version_project_types = version.project_types.clone(); exp::compat::correct_project_types( &project.components, @@ -666,16 +647,24 @@ async fn build_search_documents( let (_, v2_og_project_type) = LegacyProject::get_project_type(&version_project_types); let (client_side, server_side) = - v2_reroute::convert_v3_side_types_to_v2_side_types( - &unvectorized_loader_fields, + v2_reroute::convert_minecraft_environment_to_v2_side_types( + minecraft + .and_then(|component| component.environment) + .unwrap_or(exp::minecraft::Environment::Unknown), Some(&v2_og_project_type), ); - if let Ok(client_side) = serde_json::to_value(client_side) { - fields.insert("client_side".to_string(), vec![client_side]); - } - if let Ok(server_side) = serde_json::to_value(server_side) { - fields.insert("server_side".to_string(), vec![server_side]); - } + fields.insert( + "client_side".to_owned(), + vec![serde_json::Value::String( + client_side.as_str().to_owned(), + )], + ); + fields.insert( + "server_side".to_owned(), + vec![serde_json::Value::String( + server_side.as_str().to_owned(), + )], + ); fields.retain(|field, _| { matches!( field.as_str(), @@ -715,17 +704,20 @@ async fn build_search_documents( let (_, v2_og_project_type) = LegacyProject::get_project_type(&project_types); let (client_side, server_side) = - v2_reroute::convert_v3_side_types_to_v2_side_types( - &unvectorized_loader_fields, + v2_reroute::convert_minecraft_environment_to_v2_side_types( + project_environment + .unwrap_or(exp::minecraft::Environment::Unknown), Some(&v2_og_project_type), ); - if let Ok(client_side) = serde_json::to_value(client_side) { - loader_fields.insert("client_side".to_string(), vec![client_side]); - } - if let Ok(server_side) = serde_json::to_value(server_side) { - loader_fields.insert("server_side".to_string(), vec![server_side]); - } + loader_fields.insert( + "client_side".to_owned(), + vec![serde_json::Value::String(client_side.as_str().to_owned())], + ); + loader_fields.insert( + "server_side".to_owned(), + vec![serde_json::Value::String(server_side.as_str().to_owned())], + ); let components = project .components @@ -791,45 +783,91 @@ async fn build_search_documents( }) } +fn minecraft_search_fields<'a>( + components: impl Iterator, +) -> HashMap> { + let mut names = HashSet::new(); + let mut environments = HashSet::new(); + let mut loaders = HashSet::new(); + let mut has_minecraft = false; + for component in components { + has_minecraft = true; + names + .extend(component.game_versions.iter().map(|name| name.0.as_str())); + environments.extend(component.environment); + loaders.extend(component.mrpack_loaders.iter().copied()); + } + let mut fields = HashMap::new(); + if has_minecraft { + fields.insert("game_versions".to_owned(), search_strings(names)); + fields.insert( + "mrpack_loaders".to_owned(), + search_strings(loaders.iter().map(|loader| loader.as_str())), + ); + } + if !environments.is_empty() { + fields.insert( + "environment".to_owned(), + search_strings( + environments.iter().map(|environment| environment.as_str()), + ), + ); + } + fields +} + +fn search_strings<'a>( + values: impl IntoIterator, +) -> Vec { + values + .into_iter() + .sorted() + .map(|value| serde_json::Value::String(value.to_owned())) + .collect() +} + struct PartialVersion { id: DBVersionId, loaders: Vec, project_types: Vec, - version_fields: Vec, + components: exp::version::VersionSerial, date_published: DateTime, } async fn load_project_versions( pool: &PgPool, - redis: &RedisPool, project_ids: Vec, ) -> Result>> { - let versions: HashMap, exp::version::VersionSerial)>> = - sqlx::query!( - r#" + let versions: HashMap< + DBProjectId, + Vec<(DBVersionId, DateTime, exp::version::VersionSerial)>, + > = sqlx::query!( + r#" SELECT v.id, v.mod_id, v.date_published, v.components AS "components!: sqlx::types::Json" FROM versions v WHERE mod_id = ANY($1) "#, - &project_ids, - ) - .fetch(pool) - .try_fold( - HashMap::new(), - |mut acc: HashMap< - DBProjectId, - Vec<(DBVersionId, DateTime, exp::version::VersionSerial)>, - >, - m| { - acc.entry(DBProjectId(m.mod_id)) - .or_default() - .push((DBVersionId(m.id), m.date_published, m.components.0)); - async move { Ok(acc) } - }, - ) - .await - .wrap_err("failed to fetch versions")?; + &project_ids, + ) + .fetch(pool) + .try_fold( + HashMap::new(), + |mut acc: HashMap< + DBProjectId, + Vec<(DBVersionId, DateTime, exp::version::VersionSerial)>, + >, + m| { + acc.entry(DBProjectId(m.mod_id)).or_default().push(( + DBVersionId(m.id), + m.date_published, + m.components.0, + )); + async move { Ok(acc) } + }, + ) + .await + .wrap_err("failed to fetch versions")?; // Get project types, loaders #[derive(Default)] @@ -873,16 +911,6 @@ async fn load_project_versions( .await .wrap_err("failed to fetch loaders and project types")?; - let minecraft_catalog = if versions.values().flatten().any(|(_, _, components)| { - components.minecraft_mod.is_some() - }) { - LoaderFieldEnumValue::list(LoaderFieldEnumId(1), pool, redis) - .await - .wrap_err("fetching Minecraft version catalog for search indexing")? - } else { - Vec::new() - }; - // Convert to partial versions let mut res_versions: HashMap> = HashMap::new(); @@ -895,18 +923,6 @@ async fn load_project_versions( .map(|(_, version_loader_data)| version_loader_data) .unwrap_or_default(); - let version_fields = components.minecraft_mod.as_ref() - .map(|component| { - VersionField::from_minecraft_component_with_catalog( - *version_id, - component, - &minecraft_catalog, - ) - }) - .transpose() - .wrap_err_with(|| format!("building search fields for version {}", version_id.0))? - .unwrap_or_default(); - res_versions .entry(*project_id) .or_default() @@ -914,7 +930,7 @@ async fn load_project_versions( id: *version_id, loaders: version_loader_data.loaders, project_types: version_loader_data.project_types, - version_fields, + components: components.clone(), date_published: *date_published, }); } diff --git a/apps/labrinth/src/test/api_v3/tags.rs b/apps/labrinth/src/test/api_v3/tags.rs index 7100d5ada5..567651a485 100644 --- a/apps/labrinth/src/test/api_v3/tags.rs +++ b/apps/labrinth/src/test/api_v3/tags.rs @@ -1,6 +1,6 @@ use crate::routes::v3::tags::{GameData, LoaderData}; use crate::{ - database::models::loader_fields::LoaderFieldEnumValue, + models::minecraft::LoaderFieldEnumValue, routes::v3::tags::CategoryData, }; use actix_http::StatusCode; diff --git a/apps/labrinth/src/util/tags.rs b/apps/labrinth/src/util/tags.rs index 72a88c6667..3dad006133 100644 --- a/apps/labrinth/src/util/tags.rs +++ b/apps/labrinth/src/util/tags.rs @@ -1,6 +1,6 @@ use crate::database::PgPool; -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; -use crate::database::models::loader_fields::Loader; +use crate::database::models::loader_item::Loader; +use crate::models::minecraft::MinecraftGameVersion; use crate::routes::ApiError; use crate::util::error::Context; use arc_swap::ArcSwapOption; diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 06c5b8a0e5..7f368681f7 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -1,5 +1,5 @@ -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::models::ids::ProjectId; +use crate::models::minecraft::MinecraftGameVersion; use crate::routes::ApiError; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; @@ -116,14 +116,21 @@ async fn get_webhook_metadata( "fetching minecraft game version from Redis", )?; - let versions = project - .aggregate_version_fields - .clone() - .into_iter() - .find_map(|vf| { - MinecraftGameVersion::try_from_version_field(&vf).ok() + let supported_names = project + .minecraft_mod_versions + .iter() + .flat_map(|component| &component.game_versions) + .map(|name| { + crate::models::minecraft::legacy_game_version_name(&name.0) + }) + .collect::>(); + let versions = all_game_versions + .iter() + .filter(|version| { + supported_names.contains(version.version.as_str()) }) - .unwrap_or_default(); + .cloned() + .collect(); let formatted_game_versions = get_gv_range(versions, all_game_versions); diff --git a/apps/labrinth/src/validate/fabric.rs b/apps/labrinth/src/validate/fabric.rs index 4fe380915e..f208680e50 100644 --- a/apps/labrinth/src/validate/fabric.rs +++ b/apps/labrinth/src/validate/fabric.rs @@ -1,4 +1,4 @@ -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::models::minecraft::MinecraftGameVersion; use crate::models::projects::Loader; use crate::validate::{ SupportedGameVersions, ValidationError, ValidationResult, diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index 772d038866..4e1f3d0b1d 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -1,10 +1,9 @@ use crate::database::PgTransaction; -use crate::database::models::legacy_loader_fields::MinecraftGameVersion; -use crate::database::models::loader_fields::{ - Loader as DBLoader, VersionField, -}; +use crate::database::models::loader_item::Loader as DBLoader; use crate::database::models::version_item::DependencyBuilder; use crate::models::ids::ProjectId; +use crate::models::minecraft::MinecraftGameVersion; +use crate::models::minecraft::VersionField; use crate::models::pack::PackFormat; use crate::models::projects::{DependencyType, FileType, Loader}; use crate::validate::datapack::DataPackValidator; @@ -201,13 +200,16 @@ pub async fn validate_file( transaction: &mut PgTransaction<'_>, redis: &RedisPool, ) -> Result { - let game_versions = version_fields - .into_iter() - .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()) - .unwrap_or_default(); + let component = VersionField::to_minecraft_component(&version_fields)?; let all_game_versions = MinecraftGameVersion::list(None, None, &mut *transaction, redis) .await?; + let game_versions = component.game_versions.iter().map(|name| { + let name = crate::models::minecraft::legacy_game_version_name(&name.0); + all_game_versions.iter().find(|version| version.version == name) + .cloned() + .ok_or_else(|| eyre::eyre!("missing minecraft game version `{name}` required for validation")) + }).collect::, _>>()?; let available_loaders = DBLoader::list(&mut *transaction, redis).await?; diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index 4e5f30bb45..b9ff426c4b 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -9,11 +9,10 @@ use common::environment::{TestEnvironment, with_test_environment}; use itertools::Itertools; use labrinth::database::models::{ DBUserId, DBVersionId, - legacy_loader_fields::MinecraftGameVersion, user_limits::DBUserLimits, version_item::{VERSIONS_NAMESPACE, VersionQueryResult}, }; -use labrinth::models::v3; +use labrinth::models::{minecraft::MinecraftGameVersion, v3}; use serde_json::json; use xredis::RedisValue; From d28ea60d0344a30ba76d32f0e5344db1b7accc1f Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:43:32 +0100 Subject: [PATCH 06/13] remove loader fields from models too --- apps/labrinth/src/models/minecraft.rs | 1570 ++--------------- apps/labrinth/src/models/v3/projects.rs | 39 +- apps/labrinth/src/routes/internal/billing.rs | 10 +- .../src/routes/v3/project_creation.rs | 48 +- apps/labrinth/src/routes/v3/tags.rs | 84 +- .../src/routes/v3/version_creation.rs | 218 +-- apps/labrinth/src/routes/v3/versions.rs | 187 +- apps/labrinth/src/test/api_v3/tags.rs | 7 +- apps/labrinth/src/util/tags.rs | 6 +- apps/labrinth/src/util/webhook.rs | 60 +- apps/labrinth/src/validate/fabric.rs | 25 +- apps/labrinth/src/validate/mod.rs | 46 +- apps/labrinth/tests/loader_fields.rs | 68 +- 13 files changed, 409 insertions(+), 1959 deletions(-) diff --git a/apps/labrinth/src/models/minecraft.rs b/apps/labrinth/src/models/minecraft.rs index 09ba685310..7144814362 100644 --- a/apps/labrinth/src/models/minecraft.rs +++ b/apps/labrinth/src/models/minecraft.rs @@ -1,1382 +1,153 @@ -//! Minecraft compatibility adapters for legacy API consumers. +//! Minecraft catalog access and compatibility parsing for legacy API payloads. -use std::collections::HashMap; -use std::hash::Hasher; -use std::sync::LazyLock; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, LazyLock}; -use crate::database::{ - PgTransaction, - models::{ - ids::{DBVersionId, LoaderId}, - loader_item::Loader, - }, -}; -use crate::models::exp; use chrono::{DateTime, Utc}; -use eyre::{Result, WrapErr}; -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use xredis::RedisPool; - -/// Synthetic Minecraft compatibility identifier, not a database row ID. -#[derive( - Copy, - Clone, - Debug, - Serialize, - Deserialize, - Eq, - PartialEq, - Hash, - utoipa::ToSchema, -)] -pub struct LoaderFieldId(pub i32); - -/// Synthetic Minecraft compatibility identifier, not a database row ID. -#[derive( - Copy, - Clone, - Debug, - Serialize, - Deserialize, - Eq, - PartialEq, - Hash, - utoipa::ToSchema, -)] -pub struct LoaderFieldEnumId(pub i32); - -/// Synthetic Minecraft compatibility identifier, not a database row ID. -#[derive( - Copy, - Clone, - Debug, - Serialize, - Deserialize, - Eq, - PartialEq, - Hash, - utoipa::ToSchema, -)] -pub struct LoaderFieldEnumValueId(pub i32); - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct LoaderField { - pub id: LoaderFieldId, - pub field: String, - pub field_type: LoaderFieldType, - pub optional: bool, - pub min_val: Option, - pub max_val: Option, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub enum LoaderFieldType { - Integer, - Text, - Enum(LoaderFieldEnumId), - Boolean, - ArrayInteger, - ArrayText, - ArrayEnum(LoaderFieldEnumId), - ArrayBoolean, -} -impl LoaderFieldType { - pub fn build( - field_type_name: &str, - loader_field_enum: Option, - ) -> Option { - Some(match (field_type_name, loader_field_enum) { - ("integer", _) => LoaderFieldType::Integer, - ("text", _) => LoaderFieldType::Text, - ("boolean", _) => LoaderFieldType::Boolean, - ("array_integer", _) => LoaderFieldType::ArrayInteger, - ("array_text", _) => LoaderFieldType::ArrayText, - ("array_boolean", _) => LoaderFieldType::ArrayBoolean, - ("enum", Some(id)) => LoaderFieldType::Enum(LoaderFieldEnumId(id)), - ("array_enum", Some(id)) => { - LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) - } - _ => return None, - }) - } - - pub fn to_str(&self) -> &'static str { - match self { - LoaderFieldType::Integer => "integer", - LoaderFieldType::Text => "text", - LoaderFieldType::Boolean => "boolean", - LoaderFieldType::ArrayInteger => "array_integer", - LoaderFieldType::ArrayText => "array_text", - LoaderFieldType::ArrayBoolean => "array_boolean", - LoaderFieldType::Enum(_) => "enum", - LoaderFieldType::ArrayEnum(_) => "array_enum", - } - } - - pub fn is_array(&self) -> bool { - match self { - LoaderFieldType::ArrayInteger => true, - LoaderFieldType::ArrayText => true, - LoaderFieldType::ArrayBoolean => true, - LoaderFieldType::ArrayEnum(_) => true, - - LoaderFieldType::Integer => false, - LoaderFieldType::Text => false, - LoaderFieldType::Boolean => false, - LoaderFieldType::Enum(_) => false, - } - } -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct LoaderFieldEnum { - pub id: LoaderFieldEnumId, - pub enum_name: String, - pub ordering: Option, - pub hidable: bool, -} - -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct LoaderFieldEnumValue { - pub id: LoaderFieldEnumValueId, - pub enum_id: LoaderFieldEnumId, - pub value: String, - pub ordering: Option, - pub created: DateTime, - #[serde(rename = "type")] - pub ty: Option, - pub major: Option, -} - -impl std::hash::Hash for LoaderFieldEnumValue { - fn hash(&self, state: &mut H) { - self.id.hash(state); - self.enum_id.hash(state); - self.value.hash(state); - self.ordering.hash(state); - self.created.hash(state); - } -} - -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] -pub struct VersionField { - pub version_id: DBVersionId, - pub field_id: LoaderFieldId, - pub field_name: String, - pub value: VersionFieldValue, -} -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] -pub enum VersionFieldValue { - Integer(i32), - Text(String), - Enum(LoaderFieldEnumId, LoaderFieldEnumValue), - Boolean(bool), - ArrayInteger(Vec), - ArrayText(Vec), - ArrayEnum(LoaderFieldEnumId, Vec), - ArrayBoolean(Vec), -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct QueryVersionField { - pub version_id: DBVersionId, - pub field_id: LoaderFieldId, - pub int_value: Option, - pub enum_value: Option, - pub string_value: Option, -} - -impl QueryVersionField { - pub fn with_int_value(mut self, int_value: i32) -> Self { - self.int_value = Some(int_value); - self - } - - pub fn with_enum_value( - mut self, - enum_value: LoaderFieldEnumValueId, - ) -> Self { - self.enum_value = Some(enum_value); - self - } - - pub fn with_string_value(mut self, string_value: String) -> Self { - self.string_value = Some(string_value); - self - } -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct QueryLoaderField { - pub id: LoaderFieldId, - pub field: String, - pub field_type: String, - pub enum_type: Option, - pub min_val: Option, - pub max_val: Option, - pub optional: bool, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct QueryLoaderFieldEnumValue { - pub id: LoaderFieldEnumValueId, - pub enum_id: LoaderFieldEnumId, - pub value: String, - pub ordering: Option, - pub created: DateTime, - pub ty: Option, - pub major: Option, -} +use eyre::{Result, WrapErr, eyre}; +use minecraft_meta_model::{ + model::{MinecraftVersion, MinecraftVersionName}, + upstream::mojang::VersionType, +}; +use serde_json::Value; +use tokio::sync::OnceCell; -/// Compatibility IDs are schema identifiers, not IDs from the retired tables. -fn minecraft_fields() -> Vec { - [ - (1, "game_versions", false, true), - (2, "environment", true, false), - (3, "mrpack_loaders", true, true), - ] - .into_iter() - .map(|(id, field, optional, array)| LoaderField { - id: LoaderFieldId(id), - field: field.into(), - optional, - field_type: if array { - LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) - } else { - LoaderFieldType::Enum(LoaderFieldEnumId(id)) - }, - min_val: if id == 1 { Some(1) } else { None }, - max_val: None, - }) - .collect() -} +use crate::database::MinecraftMetaDb; +use crate::models::exp::minecraft::{Environment, ModVersionEdit, MrpackLoader}; -static MINECRAFT_METADATA: std::sync::LazyLock< - tokio::sync::OnceCell>, -> = std::sync::LazyLock::new(tokio::sync::OnceCell::new); +static MINECRAFT_METADATA: LazyLock>> = + LazyLock::new(OnceCell::new); -/// Register the application's existing metadata service before compatibility lookups. -/// Call once during setup with `minecraft_meta_db.clone()`. pub fn register_minecraft_metadata( - metadata: actix_web::web::Data, + metadata: actix_web::web::Data, ) -> Result<()> { MINECRAFT_METADATA.set(metadata.into_inner()).map_err(|_| { - eyre::eyre!("Minecraft metadata service is already initialized") + eyre!("minecraft-meta service is already initialized") }) } -pub(crate) async fn minecraft_metadata() --> Result<&'static crate::database::MinecraftMetaDb> { +async fn minecraft_metadata() -> Result<&'static MinecraftMetaDb> { Ok(MINECRAFT_METADATA .get_or_try_init(|| async { - crate::database::MinecraftMetaDb::connect() - .await - .map(std::sync::Arc::new) + MinecraftMetaDb::connect().await.map(Arc::new) }) .await? .as_ref()) } -pub(crate) fn compatibility_value( - enum_id: i32, - value: String, -) -> LoaderFieldEnumValue { - // FNV-1a is deterministic across processes and catalog ordering changes. - let hash = value.bytes().fold(2166136261u32, |hash, byte| { - (hash ^ u32::from(byte)).wrapping_mul(16777619) - }); - LoaderFieldEnumValue { - id: LoaderFieldEnumValueId((hash & 0x7fff_ffff) as i32), - enum_id: LoaderFieldEnumId(enum_id), - value, - ordering: None, - created: DateTime::::UNIX_EPOCH, - ty: None, - major: None, +/// Returns catalog records with the names historically exposed by Labrinth. +pub async fn minecraft_versions() -> Result> { + let mut connection = minecraft_metadata() + .await? + .0 + .connection() + .await + .wrap_err("acquiring minecraft-meta connection")?; + let mut versions = MinecraftVersion::all() + .exec(&mut connection) + .await + .wrap_err("fetching Minecraft game versions")?; + for version in &mut versions { + version.name = MinecraftVersionName( + legacy_game_version_name(&version.name.0).to_owned(), + ); } + versions.sort_by(|a, b| { + minecraft_version_date(b) + .cmp(&minecraft_version_date(a)) + .then_with(|| a.name.0.cmp(&b.name.0)) + }); + Ok(versions) } -impl LoaderField { - pub async fn get_field<'a, E>( - field: &str, - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::get_fields(loader_ids, exec, redis) - .await? - .into_iter() - .find(|item| item.field == field)) - } - pub async fn get_fields<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::get_fields_per_loader(loader_ids, exec, redis) - .await? - .into_values() - .flatten() - .unique_by(|field| field.id) - .collect()) - } - pub async fn get_fields_per_loader<'a, E>( - loader_ids: &[LoaderId], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Loader::list(exec, redis) - .await? - .into_iter() - .filter(|loader| loader_ids.contains(&loader.id)) - .map(|loader| { - let fields = minecraft_fields() - .into_iter() - .filter(|field| { - loader - .supported_games - .iter() - .any(|game| game == "minecraft-java") - && (field.field != "mrpack_loaders" - || loader.loader == "mrpack") - }) - .collect(); - (loader.id, fields) - }) - .collect()) - } - pub async fn get_fields_all<'a, E>( - _exec: E, - _redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(minecraft_fields()) - } +pub fn minecraft_version_date(version: &MinecraftVersion) -> DateTime { + let timestamp = version.catalog_entry.0.release_time; + let date = DateTime::from_timestamp( + timestamp.as_second(), + timestamp.subsec_nanosecond() as u32, + ) + .expect("Minecraft release timestamp is representable by chrono"); + legacy_game_version_date(&version.name.0, date) } -impl LoaderFieldEnum { - pub async fn get<'a, E>( - enum_name: &str, - _exec: E, - _redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(minecraft_fields() - .into_iter() - .find(|field| field.field == enum_name) - .map(|field| Self { - id: LoaderFieldEnumId(field.id.0), - enum_name: field.field, - ordering: Some(field.id.0), - hidable: field.id.0 == 1, - })) +pub fn minecraft_version_type(version: &MinecraftVersion) -> &'static str { + match version.catalog_entry.0.ty { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldAlpha => "alpha", + VersionType::OldBeta => "beta", } } -impl LoaderFieldEnumValue { - /// Fetches authoritative game-version metadata using an explicitly supplied service. - pub async fn minecraft_catalog( - db: &crate::database::MinecraftMetaDb, - ) -> Result> { - let values = { - let mut conn = - db.0.connection() - .await - .wrap_err("acquiring Minecraft metadata connection")?; - minecraft_meta_model::model::MinecraftVersion::all() - .exec(&mut conn) - .await - .wrap_err("fetching Minecraft version catalog")? - .into_iter() - .map(|row| { - let name = row.name.to_string(); - let mut value = compatibility_value( - 1, - legacy_game_version_name(&name).to_owned(), - ); - value.created = DateTime::::from_timestamp( - row.catalog_entry.0.release_time.as_second(), - row.catalog_entry.0.release_time.subsec_nanosecond() - as u32, - ) - .expect("Minecraft timestamp is representable by chrono"); - value.created = - legacy_game_version_date(&name, value.created); - use minecraft_meta_model::upstream::mojang::VersionType; - value.ty = Some( - match row.catalog_entry.0.ty { - VersionType::Release => "release", - VersionType::Snapshot => "snapshot", - VersionType::OldAlpha => "alpha", - VersionType::OldBeta => "beta", - } - .to_owned(), - ); - value.major = Some(value.ty.as_deref() == Some("release")); - value - }) - .collect::>() - }; - let mut seen = std::collections::HashSet::new(); +pub async fn validate_minecraft_versions( + names: &[MinecraftVersionName], +) -> Result<()> { + if names.is_empty() { + return Ok(()); + } + let versions = minecraft_versions().await?; + let known = versions.iter().map(|version| &version.name).collect::>(); + for name in names { eyre::ensure!( - values.iter().all(|value| seen.insert(value.id)), - "compatibility enum value ID collision" + known.contains(name), + "Minecraft version `{name}` is absent from minecraft-meta" ); - Ok(values) - } - - pub async fn list<'a, E>( - id: LoaderFieldEnumId, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list_many(&[id], exec, redis) - .await? - .remove(&id) - .unwrap_or_default()) } - pub async fn list_many_loader_fields<'a, E>( - fields: &[LoaderField], - exec: E, - redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - let ids = fields - .iter() - .filter_map(|field| match field.field_type { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - Some(id) - } - _ => None, - }) - .collect::>(); - let values = Self::list_many(&ids, exec, redis).await?; - Ok(fields - .iter() - .filter_map(|field| match field.field_type { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - Some((field.id, values[&id].clone())) - } - _ => None, - }) - .collect()) - } - pub async fn list_many<'a, E>( - ids: &[LoaderFieldEnumId], - _exec: E, - _redis: &RedisPool, - ) -> Result>> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - use crate::models::exp::minecraft::{Environment, MrpackLoader}; - use strum::IntoEnumIterator; - let mut result = HashMap::new(); - for &id in ids { - let mut values = match id.0 { - 1 => { - Self::minecraft_catalog(minecraft_metadata().await?).await? - } - 2 => Environment::iter() - .map(|value| { - compatibility_value(2, value.as_str().to_owned()) - }) - .collect(), - 3 => MrpackLoader::iter() - .map(|value| { - compatibility_value(3, value.as_str().to_owned()) - }) - .collect(), - _ => eyre::bail!("unknown compatibility enum ID {}", id.0), - }; - values.sort_by(|a, b| { - b.created - .cmp(&a.created) - .then_with(|| a.value.cmp(&b.value)) - }); - let mut seen = std::collections::HashSet::new(); - eyre::ensure!( - values.iter().all(|value| seen.insert(value.id)), - "compatibility enum value ID collision" - ); - result.insert(id, values); - } - Ok(result) - } - pub async fn list_filter<'a, E>( - id: LoaderFieldEnumId, - filter: HashMap, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list(id, exec, redis) - .await? - .into_iter() - .filter(|value| { - filter.iter().all(|(key, expected)| match key.as_str() { - "type" => value - .ty - .as_deref() - .is_some_and(|ty| expected.as_str() == Some(ty)), - "major" => value - .major - .is_some_and(|major| expected.as_bool() == Some(major)), - _ => false, - }) - }) - .collect()) - } -} - -impl VersionField { - /// Converts compatibility field names and enum values directly into typed metadata. - pub fn to_minecraft_component( - fields: &[VersionField], - ) -> Result { - use minecraft_meta_model::model::MinecraftVersionName; - - let mut component = exp::minecraft::ModVersion { - game_versions: Vec::new(), - environment: None, - mrpack_loaders: Vec::new(), - }; - let mut seen = std::collections::HashSet::new(); - for field in fields { - eyre::ensure!( - seen.insert(field.field_name.as_str()), - "duplicate Minecraft compatibility field `{}`", - field.field_name - ); - match (field.field_name.as_str(), &field.value) { - ("game_versions", VersionFieldValue::ArrayEnum(_, values)) => { - component.game_versions = values - .iter() - .map(|value| MinecraftVersionName(value.value.clone())) - .collect(); - } - ("environment", VersionFieldValue::Enum(_, value)) => { - component.environment = Some( - value - .value - .parse() - .wrap_err("parsing Minecraft environment")?, - ); - } - ("mrpack_loaders", VersionFieldValue::ArrayEnum(_, values)) => { - component.mrpack_loaders = values - .iter() - .map(|value| { - value - .value - .parse() - .wrap_err("parsing Minecraft mrpack loader") - }) - .collect::>>()?; - } - ("game_versions" | "environment" | "mrpack_loaders", _) => { - eyre::bail!( - "invalid value type for Minecraft field `{}`", - field.field_name - ); - } - _ => eyre::bail!( - "unknown Minecraft compatibility field `{}`", - field.field_name - ), - } - } - Ok(component) - } - - /// Hydrates game versions before exposing legacy dates to validation or webhooks. - pub async fn from_minecraft_component( - version_id: DBVersionId, - component: &crate::models::exp::minecraft::ModVersion, - ) -> Result> { - let catalog = LoaderFieldEnumValue::minecraft_catalog( - minecraft_metadata().await?, - ) - .await?; - Self::from_minecraft_component_with_catalog( - version_id, component, &catalog, - ) - } - - /// Reuses one catalog across a batch of versions without additional database reads. - pub fn from_minecraft_component_with_catalog( - version_id: DBVersionId, - component: &crate::models::exp::minecraft::ModVersion, - catalog: &[LoaderFieldEnumValue], - ) -> Result> { - let mut fields = vec![ - Self { - version_id, - field_id: LoaderFieldId(1), - field_name: "game_versions".into(), - value: VersionFieldValue::ArrayEnum( - LoaderFieldEnumId(1), - component - .game_versions - .iter() - .map(|name| { - catalog - .iter() - .find(|value| { - value.enum_id == LoaderFieldEnumId(1) - && value.value == name.0 - }) - .cloned() - .ok_or_else(|| { - eyre::eyre!( - "Minecraft version `{name}` is absent from the metadata catalog" - ) - }) - }) - .collect::>>()?, - ), - }, - Self { - version_id, - field_id: LoaderFieldId(3), - field_name: "mrpack_loaders".into(), - value: VersionFieldValue::ArrayEnum( - LoaderFieldEnumId(3), - component - .mrpack_loaders - .iter() - .map(|value| { - compatibility_value(3, value.as_str().to_owned()) - }) + Ok(()) +} + +/// Parses only the three flattened Minecraft fields retained by the v3 API. +pub fn parse_minecraft_fields( + fields: &HashMap, +) -> Result { + let mut edit = ModVersionEdit { + game_versions: None, + environment: None, + mrpack_loaders: None, + }; + for (name, value) in fields { + match name.as_str() { + "game_versions" => { + edit.game_versions = Some( + string_array(value, name)? + .into_iter() + .map(|name| MinecraftVersionName(name.to_owned())) .collect(), - ), - }, - ]; - if let Some(environment) = component.environment { - fields.push(Self { - version_id, - field_id: LoaderFieldId(2), - field_name: "environment".into(), - value: VersionFieldValue::Enum( - LoaderFieldEnumId(2), - compatibility_value(2, environment.as_str().to_owned()), - ), - }); - } - Ok(fields) - } - - pub async fn insert_many( - items: Vec, - transaction: &mut PgTransaction<'_>, - ) -> Result<()> { - let mut patches = HashMap::< - DBVersionId, - serde_json::Map, - >::new(); - for item in items { - let schema = minecraft_fields() - .into_iter() - .find(|field| { - field.field == item.field_name && field.id == item.field_id - }) - .ok_or_else(|| { - eyre::eyre!( - "unknown Minecraft compatibility field `{}`", - item.field_name - ) - })?; - let value = match (&schema.field_type, item.value) { - ( - LoaderFieldType::Enum(id), - VersionFieldValue::Enum(actual, value), - ) if *id == actual => { - let _: crate::models::exp::minecraft::Environment = - value.value.parse()?; - serde_json::Value::String(value.value) - } - ( - LoaderFieldType::ArrayEnum(id), - VersionFieldValue::ArrayEnum(actual, values), - ) if *id == actual => { - if item.field_name == "mrpack_loaders" { - for value in &values { - let _: crate::models::exp::minecraft::MrpackLoader = - value.value.parse()?; - } - } - serde_json::Value::Array( - values - .into_iter() - .map(|value| serde_json::Value::String(value.value)) - .collect(), - ) - } - _ => eyre::bail!( - "invalid value type for Minecraft field `{}`", - item.field_name - ), - }; - - patches - .entry(item.version_id) - .or_default() - .insert(item.field_name, value); - } - for (version_id, patch) in patches { - let patch = serde_json::Value::Object(patch); - let result = sqlx::query!( - r#" - UPDATE versions SET components = jsonb_set( - COALESCE(components, '{}'::jsonb), '{minecraft_mod}', - jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb) - || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb) - || $2::jsonb - ) WHERE id = $1 - "#, - version_id.0, - patch - ) - .execute(&mut *transaction) - .await - .wrap_err("updating Minecraft version component")?; - eyre::ensure!( - result.rows_affected() == 1, - "version {} does not exist", - version_id.0 - ); - } - Ok(()) - } - - pub fn check_parse( - version_id: DBVersionId, - loader_field: LoaderField, - value: serde_json::Value, - enum_variants: Vec, - ) -> Result { - let value = - VersionFieldValue::parse(&loader_field, value, enum_variants)?; - - // Ensure, if applicable, that the value is within the min/max bounds - let countable = match &value { - VersionFieldValue::Integer(i) => Some(*i), - VersionFieldValue::ArrayInteger(v) => Some(v.len() as i32), - VersionFieldValue::Text(_) => None, - VersionFieldValue::ArrayText(v) => Some(v.len() as i32), - VersionFieldValue::Boolean(_) => None, - VersionFieldValue::ArrayBoolean(v) => Some(v.len() as i32), - VersionFieldValue::Enum(_, _) => None, - VersionFieldValue::ArrayEnum(_, v) => Some(v.len() as i32), - }; - - if let Some(count) = countable { - if let Some(min) = loader_field.min_val - && count < min - { - return Err(format!( - "Provided value '{v}' for {field_name} is less than the minimum of {min}", - v = serde_json::to_string(&value).unwrap_or_default(), - field_name = loader_field.field, - )); - } - - if let Some(max) = loader_field.max_val - && count > max - { - return Err(format!( - "Provided value '{v}' for {field_name} is greater than the maximum of {max}", - v = serde_json::to_string(&value).unwrap_or_default(), - field_name = loader_field.field, - )); + ); } - } - - Ok(VersionField { - version_id, - field_id: loader_field.id, - field_name: loader_field.field, - value, - }) - } - - pub fn from_query_json( - // A list of all version fields to extract data from - query_version_field_combined: Vec, - // A list of all loader fields to reference when extracting data - // Note: any loader field in here that is not in query_version_field_combined will be still considered - // (For example, game_versions in query_loader_fields but not in query_version_field_combined would produce game_versions: []) - query_loader_fields: &[&QueryLoaderField], - // enum values to reference when parsing enum values - query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], - // If true, will allow multiple values for a single singleton field, returning them as separate VersionFields - // allow_many = true, multiple Bools => two VersionFields of Bool - // allow_many = false, multiple Bools => error - // multiple Arraybools => 1 VersionField of ArrayBool - allow_many: bool, - ) -> Vec { - query_loader_fields - .iter() - .flat_map(|q| { - let Some(loader_field_type) = LoaderFieldType::build( - &q.field_type, - q.enum_type.map(|l| l.0), - ) else { - return vec![]; - }; - let loader_field = LoaderField { - id: q.id, - field: q.field.clone(), - field_type: loader_field_type, - optional: q.optional, - min_val: q.min_val, - max_val: q.max_val, - }; - - // todo: avoid clone here? - let version_fields = query_version_field_combined - .iter() - .filter(|qvf| qvf.field_id == q.id) - .cloned() - .collect::>(); - if allow_many { - VersionField::build_many( - loader_field, - version_fields, - query_loader_field_enum_values, - ) - .unwrap_or_default() - .into_iter() - .unique() - .collect_vec() + "environment" => { + edit.environment = Some(if value.is_null() { + None } else { - match VersionField::build( - loader_field, - version_fields, - query_loader_field_enum_values, - ) { - Ok(vf) => vec![vf], - Err(_) => vec![], - } - } - }) - .collect() - } - - pub fn build( - loader_field: LoaderField, - query_version_fields: Vec, - query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], - ) -> Result { - let (version_id, value) = VersionFieldValue::build( - &loader_field.field_type, - query_version_fields, - query_loader_field_enum_values, - ) - .wrap_err("building version field value")?; - Ok(VersionField { - version_id, - field_id: loader_field.id, - field_name: loader_field.field, - value, - }) - } - - pub fn build_many( - loader_field: LoaderField, - query_version_fields: Vec, - query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], - ) -> Result> { - let values = VersionFieldValue::build_many( - &loader_field.field_type, - query_version_fields, - query_loader_field_enum_values, - ) - .wrap_err("building version field values")?; - Ok(values - .into_iter() - .map(|(version_id, value)| VersionField { - version_id, - field_id: loader_field.id, - field_name: loader_field.field.clone(), - value, - }) - .collect()) - } -} - -impl VersionFieldValue { - // Build from user-submitted JSON data - // value is the attempted value of the field, which will be tried to parse to the correct type - // enum_array is the list of valid enum variants for the field, if it is an enum (see LoaderFieldEnumValue::list_many_loader_fields) - pub fn parse( - loader_field: &LoaderField, - value: serde_json::Value, - enum_array: Vec, - ) -> Result { - let field_name = &loader_field.field; - let field_type = &loader_field.field_type; - - let error_value = value.clone(); - let incorrect_type_error = |field_type: &str| { - format!( - "Provided value '{v}' for {field_name} could not be parsed to {field_type} ", - v = serde_json::to_string(&error_value).unwrap_or_default() - ) - }; - - Ok(match field_type { - LoaderFieldType::Integer => VersionFieldValue::Integer( - serde_json::from_value(value) - .map_err(|_| incorrect_type_error("integer"))?, - ), - LoaderFieldType::Text => VersionFieldValue::Text( - value - .as_str() - .ok_or_else(|| incorrect_type_error("string"))? - .to_string(), - ), - LoaderFieldType::Boolean => VersionFieldValue::Boolean( - value - .as_bool() - .ok_or_else(|| incorrect_type_error("boolean"))?, - ), - LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger({ - let array_values: Vec = serde_json::from_value(value) - .map_err(|_| incorrect_type_error("array of integers"))?; - array_values.into_iter().collect() - }), - LoaderFieldType::ArrayText => VersionFieldValue::ArrayText({ - let array_values: Vec = serde_json::from_value(value) - .map_err(|_| { - incorrect_type_error("array of strings") - })?; - array_values.into_iter().collect() - }), - LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean({ - let array_values: Vec = serde_json::from_value(value) - .map_err(|_| incorrect_type_error("array of booleans"))?; - array_values.into_iter().map(|v| v != 0).collect() - }), - LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, { - let enum_value = value - .as_str() - .ok_or_else(|| incorrect_type_error("enum"))?; - if let Some(ev) = - enum_array.into_iter().find(|v| v.value == enum_value) - { - ev - } else { - return Err(format!( - "Provided value '{enum_value}' is not a valid variant for {field_name}" - )); - } - }), - LoaderFieldType::ArrayEnum(id) => { - VersionFieldValue::ArrayEnum(*id, { - let array_values: Vec = - serde_json::from_value(value).map_err(|_| { - incorrect_type_error("array of enums") - })?; - let mut enum_values = vec![]; - for av in array_values { - if let Some(ev) = - enum_array.iter().find(|v| v.value == av) - { - enum_values.push(ev.clone()); - } else { - return Err(format!( - "Provided value '{av}' is not a valid variant for {field_name}" - )); - } - } - enum_values - }) - } - }) - } - - // This will ensure that if multiple QueryVersionFields are provided, they can be combined into a single VersionFieldValue - // of the appropriate type (ie: false, false, true -> ArrayBoolean([false, false, true])) (and not just Boolean) - pub fn build( - field_type: &LoaderFieldType, - qvfs: Vec, - qlfev: &[QueryLoaderFieldEnumValue], - ) -> Result<(DBVersionId, VersionFieldValue)> { - match field_type { - LoaderFieldType::Integer - | LoaderFieldType::Text - | LoaderFieldType::Boolean - | LoaderFieldType::Enum(_) => { - let mut fields = Self::build_many(field_type, qvfs, qlfev) - .wrap_err("building singleton version field values")?; - if fields.len() > 1 { - return Err(eyre::eyre!( - "Multiple fields for field {}", - field_type.to_str() - )); - } - fields.pop().ok_or_else(|| { - eyre::eyre!( - "No version fields for field {}", - field_type.to_str() - ) - }) - } - LoaderFieldType::ArrayInteger - | LoaderFieldType::ArrayText - | LoaderFieldType::ArrayBoolean - | LoaderFieldType::ArrayEnum(_) => { - let fields = Self::build_many(field_type, qvfs, qlfev) - .wrap_err("building array version field values")?; - fields.into_iter().next().ok_or_else(|| { - eyre::eyre!( - "No version fields for field {}", - field_type.to_str() - ) - }) - } - } - } - - // Build from internal query data - // This encapsulates redundant behavior in db query -> object conversions - // This allows for multiple fields to be built at once. If there are multiple fields, - // but the type only allows for a single field, then multiple VersionFieldValues will be returned - // If there are multiple fields, and the type allows for multiple fields, then a single VersionFieldValue will be returned (array.len == 1) - pub fn build_many( - field_type: &LoaderFieldType, - qvfs: Vec, - qlfev: &[QueryLoaderFieldEnumValue], - ) -> Result> { - let field_name = field_type.to_str(); - let did_not_exist_error = |field_name: &str, desired_field: &str| { - eyre::eyre!( - "Field name {desired_field} for field {field_name} in does not exist" - ) - }; - - // Check errors- version_id must all be the same - // If the field type is a non-array, then the reason for multiple version ids is that there are multiple versions being aggregated, and those version ids are contained within. - // If the field type is an array, then the reason for multiple version ids is that there are multiple values for a single version - // (or a greater aggregation between multiple arrays, in which case the per-field version is lost, so we just take the first one and use it for that) - let version_id = qvfs - .iter() - .map(|qvf| qvf.version_id) - .unique() - .next() - .unwrap_or(DBVersionId(0)); - - if qvfs.iter().map(|qvf| qvf.field_id).unique().count() > 1 { - return Err(eyre::eyre!( - "Multiple field ids for field {field_name}" - )); - } - - let mut value = match field_type { - // Singleton fields - // If there are multiple, we assume multiple versions are being concatenated - LoaderFieldType::Integer => qvfs - .into_iter() - .map(|qvf| { - eyre::Ok(( - qvf.version_id, - VersionFieldValue::Integer( - qvf.int_value - .ok_or_else(|| { - did_not_exist_error(field_name, "int_value") - }) - .wrap_err( - "reading integer version field value", - )?, - ), - )) - }) - .collect::>>() - .wrap_err("building integer version field values")?, - LoaderFieldType::Text => qvfs - .into_iter() - .map(|qvf| { - eyre::Ok(( - qvf.version_id, - VersionFieldValue::Text( - qvf.string_value - .ok_or_else(|| { - did_not_exist_error( - field_name, - "string_value", - ) - }) - .wrap_err("reading text version field value")?, - ), - )) - }) - .collect::>>() - .wrap_err("building text version field values")?, - LoaderFieldType::Boolean => qvfs - .into_iter() - .map(|qvf| { - eyre::Ok(( - qvf.version_id, - VersionFieldValue::Boolean( - qvf.int_value - .ok_or_else(|| { - did_not_exist_error(field_name, "int_value") - }) - .wrap_err( - "reading boolean version field value", - )? - != 0, - ), - )) - }) - .collect::>>() - .wrap_err("building boolean version field values")?, - LoaderFieldType::Enum(id) => qvfs - .into_iter() - .map(|qvf| { - eyre::Ok(( - qvf.version_id, - VersionFieldValue::Enum(*id, { - let enum_id = qvf - .enum_value - .ok_or_else(|| { - did_not_exist_error( - field_name, - "enum_value", - ) - }) - .wrap_err("reading enum version field value")?; - let lfev = qlfev - .iter() - .find(|x| x.id == enum_id) - .ok_or_else(|| { - did_not_exist_error( - field_name, - "enum_value", - ) - }) - .wrap_err("finding loader field enum value")?; - LoaderFieldEnumValue { - id: lfev.id, - enum_id: lfev.enum_id, - value: lfev.value.clone(), - ordering: lfev.ordering, - created: lfev.created, - ty: lfev.ty.clone(), - major: lfev.major, - } - }), - )) - }) - .collect::>>() - .wrap_err("building enum version field values")?, - - // Array fields - // We concatenate into one array - LoaderFieldType::ArrayInteger => vec![( - version_id, - VersionFieldValue::ArrayInteger( - qvfs.into_iter() - .map(|qvf| { - qvf.int_value.ok_or_else(|| { - did_not_exist_error(field_name, "int_value") - }) - }) - .collect::>() - .wrap_err("building integer array version field value")?, - ), - )], - LoaderFieldType::ArrayText => vec![( - version_id, - VersionFieldValue::ArrayText( - qvfs.into_iter() - .map(|qvf| { - qvf.string_value.ok_or_else(|| { - did_not_exist_error(field_name, "string_value") - }) - }) - .collect::>() - .wrap_err("building text array version field value")?, - ), - )], - LoaderFieldType::ArrayBoolean => vec![( - version_id, - VersionFieldValue::ArrayBoolean( - qvfs.into_iter() - .map(|qvf| { - eyre::Ok( - qvf.int_value - .ok_or_else(|| { - did_not_exist_error( - field_name, - "int_value", - ) - }) - .wrap_err( - "reading boolean array version field value", - )? - != 0, - ) - }) - .collect::>() - .wrap_err("building boolean array version field value")?, - ), - )], - LoaderFieldType::ArrayEnum(id) => vec![( - version_id, - VersionFieldValue::ArrayEnum( - *id, - qvfs.into_iter() - .map(|qvf| { - let enum_id = qvf - .enum_value - .ok_or_else(|| { - did_not_exist_error(field_name, "enum_value") - }) - .wrap_err( - "reading enum array version field value", - )?; - let lfev = qlfev - .iter() - .find(|x| x.id == enum_id) - .ok_or_else(|| { - did_not_exist_error(field_name, "enum_value") - }) - .wrap_err( - "finding loader field enum array value", - )?; - eyre::Ok(LoaderFieldEnumValue { - id: lfev.id, - enum_id: lfev.enum_id, - value: lfev.value.clone(), - ordering: lfev.ordering, - created: lfev.created, - ty: lfev.ty.clone(), - major: lfev.major, - }) - }) - .collect::>() - .wrap_err("building enum array version field value")?, - ), - )], - }; - - // Sort arrayenums by ordering, then by created - for (_, v) in &mut value { - if let VersionFieldValue::ArrayEnum(_, v) = v { - v.sort_by(|a, b| { - a.ordering.cmp(&b.ordering).then(a.created.cmp(&b.created)) + Some(value.as_str() + .ok_or_else(|| eyre!("`environment` must be a string"))? + .parse::()?) }); } - } - - Ok(value) - } - - // Serialize to internal value, such as for converting to user-facing JSON - pub fn serialize_internal(&self) -> serde_json::Value { - match self { - VersionFieldValue::Integer(i) => { - serde_json::Value::Number((*i).into()) - } - VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), - VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), - VersionFieldValue::ArrayInteger(v) => serde_json::Value::Array( - v.iter() - .map(|i| serde_json::Value::Number((*i).into())) - .collect(), - ), - VersionFieldValue::ArrayText(v) => serde_json::Value::Array( - v.iter() - .map(|s| serde_json::Value::String(s.clone())) - .collect(), - ), - VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array( - v.iter().map(|b| serde_json::Value::Bool(*b)).collect(), - ), - VersionFieldValue::Enum(_, v) => { - serde_json::Value::String(v.value.clone()) - } - VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array( - v.iter() - .map(|v| serde_json::Value::String(v.value.clone())) - .collect(), - ), - } - } - - // For conversion to an internal string(s), such as for search facets, filtering, or direct hardcoding - // No matter the type, it will be converted to a Vec, where the non-array types will have a single element - pub fn as_strings(&self) -> Vec { - match self { - VersionFieldValue::Integer(i) => vec![i.to_string()], - VersionFieldValue::Text(s) => vec![s.clone()], - VersionFieldValue::Boolean(b) => vec![b.to_string()], - VersionFieldValue::ArrayInteger(v) => { - v.iter().map(|i| i.to_string()).collect() - } - VersionFieldValue::ArrayText(v) => v.clone(), - VersionFieldValue::ArrayBoolean(v) => { - v.iter().map(|b| b.to_string()).collect() - } - VersionFieldValue::Enum(_, v) => vec![v.value.clone()], - VersionFieldValue::ArrayEnum(_, v) => { - v.iter().map(|v| v.value.clone()).collect() + "mrpack_loaders" => { + edit.mrpack_loaders = Some( + string_array(value, name)? + .into_iter() + .map(str::parse::) + .collect::>>()?, + ); } + _ => return Err(eyre!("unknown Minecraft field `{name}`")), } } + Ok(edit) +} - pub fn contains_json_value(&self, value: &serde_json::Value) -> bool { - match self { - VersionFieldValue::Integer(i) => value.as_i64() == Some(*i as i64), - VersionFieldValue::Text(s) => value.as_str() == Some(s), - VersionFieldValue::Boolean(b) => value.as_bool() == Some(*b), - VersionFieldValue::ArrayInteger(v) => { - value.as_i64().is_some_and(|i| v.contains(&(i as i32))) - } - VersionFieldValue::ArrayText(v) => { - value.as_str().is_some_and(|s| v.contains(&s.to_string())) - } - VersionFieldValue::ArrayBoolean(v) => { - value.as_bool().is_some_and(|b| v.contains(&b)) - } - VersionFieldValue::Enum(_, v) => value.as_str() == Some(&v.value), - VersionFieldValue::ArrayEnum(_, v) => value - .as_str() - .is_some_and(|s| v.iter().any(|v| v.value == s)), - } - } +fn string_array<'a>(value: &'a Value, field: &str) -> Result> { + value.as_array() + .ok_or_else(|| eyre!("`{field}` must be an array"))? + .iter() + .map(|value| value.as_str() + .ok_or_else(|| eyre!("`{field}` must contain only strings"))) + .collect() } /// Mojang version names that require aliases for historical Labrinth compatibility. -// A list of version names that contains spaces. -// Generated using the command -// ```sh -// curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ -// | jq '[.versions[].id | select(contains(" "))]' -// ``` pub(crate) const HALL_OF_SHAME: [(&str, &str); 12] = [ ("1.14.2 Pre-Release 4", "1.14.2-pre4"), ("1.14.2 Pre-Release 3", "1.14.2-pre3"), @@ -1392,16 +163,13 @@ pub(crate) const HALL_OF_SHAME: [(&str, &str); 12] = [ ("3D Shareware v1.34", "3D-Shareware-v1.34"), ]; -/// Preserves historical Labrinth aliases while leaving other catalog names unchanged. pub(crate) fn legacy_game_version_name(name: &str) -> &str { - HALL_OF_SHAME - .iter() + HALL_OF_SHAME.iter() .find_map(|(canonical, legacy)| (*canonical == name).then_some(*legacy)) .unwrap_or(name) } -/// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, -/// as most of our ordering logic is with DateTime +/// Mojank timestamps corrected to preserve historical release ordering. pub(crate) static HALL_OF_SHAME_2: LazyLock<[(&str, DateTime); 4]> = LazyLock::new(|| { [ @@ -1411,127 +179,15 @@ pub(crate) static HALL_OF_SHAME_2: LazyLock<[(&str, DateTime); 4]> = ("13w37b", "2013-09-13T10:54:42+00:00"), ] .map(|(version, date)| { - ( - version, - DateTime::parse_from_rfc3339(date) - .unwrap() - .with_timezone(&Utc), - ) + (version, DateTime::parse_from_rfc3339(date).unwrap().with_timezone(&Utc)) }) }); -/// Preserves the historical ordering of releases with identical upstream timestamps. pub(crate) fn legacy_game_version_date( name: &str, date: DateTime, ) -> DateTime { - HALL_OF_SHAME_2 - .iter() - .find_map(|(version, corrected)| { - (*version == name).then_some(*corrected) - }) + HALL_OF_SHAME_2.iter() + .find_map(|(version, corrected)| (*version == name).then_some(*corrected)) .unwrap_or(date) } - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct MinecraftGameVersion { - pub id: LoaderFieldEnumValueId, - pub version: String, - #[serde(rename = "type")] - pub type_: String, - pub created: DateTime, - pub major: bool, -} - -impl MinecraftGameVersion { - // The name under which this legacy field is stored as a LoaderField - pub const FIELD_NAME: &'static str = "game_versions"; - - pub async fn list<'a, E>( - version_type_option: Option<&str>, - major_option: Option, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Acquire<'a, Database = sqlx::Postgres>, - { - let mut exec = exec - .acquire() - .await - .wrap_err("acquiring database connection")?; - let game_version_enum = - LoaderFieldEnum::get(Self::FIELD_NAME, &mut exec, redis) - .await - .wrap_err("fetching game version enum")? - .ok_or_else(|| eyre::eyre!("Could not find game version enum.")) - .wrap_err("finding game version enum")?; - let game_version_enum_values = - LoaderFieldEnumValue::list(game_version_enum.id, &mut exec, redis) - .await - .wrap_err("fetching game version enum values")?; - - let game_versions = game_version_enum_values - .into_iter() - .map(MinecraftGameVersion::from_enum_value) - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = version_type_option { - bool &= &*x.type_ == version_type; - } - if let Some(major) = major_option { - bool &= x.major == major; - } - - bool - }) - .collect_vec(); - - Ok(game_versions) - } - - // Tries to create a MinecraftGameVersion from a VersionField - // Clones on success - pub fn try_from_version_field( - version_field: &VersionField, - ) -> Result> { - if version_field.field_name != Self::FIELD_NAME { - return Err(eyre::eyre!( - "Field name {} is not {}", - version_field.field_name, - Self::FIELD_NAME - )); - } - let game_versions = match version_field.clone() { - VersionField { - value: VersionFieldValue::ArrayEnum(_, values), - .. - } => values.into_iter().map(Self::from_enum_value).collect(), - VersionField { - value: VersionFieldValue::Enum(_, value), - .. - } => { - vec![Self::from_enum_value(value)] - } - _ => { - return Err(eyre::eyre!( - "Game version requires field value to be an enum: {version_field:?}" - )); - } - }; - Ok(game_versions) - } - - pub fn from_enum_value( - loader_field_enum_value: LoaderFieldEnumValue, - ) -> MinecraftGameVersion { - MinecraftGameVersion { - id: loader_field_enum_value.id, - version: loader_field_enum_value.value, - created: loader_field_enum_value.created, - type_: loader_field_enum_value.ty.unwrap_or_default(), - major: loader_field_enum_value.major.unwrap_or_default(), - } - } -} diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index f5f915d4f6..1eebd87c72 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -1,6 +1,6 @@ -use crate::models::minecraft::VersionField; + use std::collections::HashMap; -use std::mem; + use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; @@ -13,7 +13,7 @@ use crate::models::minecraft::legacy_game_version_name; use crate::routes::{FileHash, HashAlgorithm}; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; -use itertools::Itertools; + use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; @@ -109,36 +109,6 @@ pub struct Project { pub fields: HashMap>, } -// This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values -// This allows for removal of duplicates -pub fn from_duplicate_version_fields( - version_fields: Vec, -) -> HashMap> { - let mut fields: HashMap> = HashMap::new(); - for vf in version_fields { - // We use a string directly, so we can remove duplicates - let serialized = if let Some(inner_array) = - vf.value.serialize_internal().as_array() - { - inner_array.clone() - } else { - vec![vf.value.serialize_internal()] - }; - - // Create array if doesnt exist, otherwise push, or if json is an array, extend - if let Some(arr) = fields.get_mut(&vf.field_name) { - arr.extend(serialized); - } else { - fields.insert(vf.field_name, serialized); - } - } - - // Remove duplicates - for v in fields.values_mut() { - *v = mem::take(v).into_iter().unique().collect_vec(); - } - fields -} /// Projects typed Minecraft metadata into the flattened compatibility fields. fn minecraft_version_fields( @@ -859,8 +829,7 @@ pub struct Version { #[serde(flatten)] pub components: exp::VersionQuery, - // All other fields are loader-specific VersionFields - // These are flattened during serialization + // Legacy Minecraft compatibility fields are flattened during serialization. #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 5e0622c585..6e4cac49f7 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -2311,19 +2311,15 @@ pub async fn stripe_webhook( ) } else { // Create a server with the latest version of Minecraft - let minecraft_versions = crate::models::minecraft::MinecraftGameVersion::list( - Some("release"), - None, - &**pool, - &redis, - ).await.wrap_internal_err("fetching minecraft versions from database")?; + let minecraft_versions = crate::models::minecraft::minecraft_versions() + .await.wrap_internal_err("fetching minecraft version catalog")?; ( None, None, serde_json::json!({ "loader": "Vanilla", - "game_version": minecraft_versions.first().map(|x| x.version.clone()), + "game_version": minecraft_versions.iter().find(|x| x.catalog_entry.0.ty == minecraft_meta_model::upstream::mojang::VersionType::Release).map(|x| x.name.0.clone()), "loader_version": "" }), ) diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index fa5a4d7d98..eebe1b84b3 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -1,4 +1,4 @@ -use super::version_creation::{InitialVersionData, try_create_version_fields}; +use super::version_creation::{InitialVersionData, create_minecraft_component}; use crate::auth::{ AuthenticationError, get_user_from_headers, require_verified_email, }; @@ -13,9 +13,6 @@ use crate::models::exp; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::link_platform::LinkPlatform; -use crate::models::minecraft::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; use crate::models::pats::Scopes; use crate::models::projects::{ License, Link, MonetizationStatus, Project, ProjectStatus, @@ -819,11 +816,8 @@ async fn project_create_inner( .iter() .map(|x| x.filename.clone()) .collect(); - let version_fields = if let Some(component) = &created_version.components.minecraft_mod { - VersionField::from_minecraft_component(created_version.version_id, component).await? - } else { - Vec::new() - }; + let game_versions = created_version.components.minecraft_mod.as_ref() + .map(|component| component.game_versions.clone()).unwrap_or_default(); // Upload the new jar file super::version_creation::upload_file( &mut field, @@ -835,7 +829,7 @@ async fn project_create_inner( &content_disposition, project_id, created_version.version_id.into(), - &version_fields, + &game_versions, version_data.loaders.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), @@ -1158,7 +1152,7 @@ async fn create_initial_version( author: UserId, all_loaders: &[models::loader_item::Loader], transaction: &mut PgTransaction<'_>, - redis: &RedisPool, + _redis: &RedisPool, ) -> Result { if version_data.project_id.is_some() { return Err(CreateError::InvalidInput(String::from( @@ -1186,23 +1180,17 @@ async fn create_initial_version( }) .collect::, CreateError>>()?; - let loader_fields = - LoaderField::get_fields(&loaders, &mut *transaction, redis).await?; - let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields( - &loader_fields, - &mut *transaction, - redis, - ) - .await?; - - let version_fields = try_create_version_fields( - version_id, + let selected_loaders = all_loaders + .iter() + .filter(|loader| loaders.contains(&loader.id)) + .cloned() + .collect::>(); + let component = create_minecraft_component( &version_data.fields, version_data.minecraft_mod.as_ref(), - &loader_fields, - &mut loader_field_enum_values, - )?; + &selected_loaders, + ) + .await?; let dependencies = version_data .dependencies @@ -1231,13 +1219,7 @@ async fn create_initial_version( requested_status: None, ordering: version_data.ordering, components: exp::VersionSerial { - minecraft_mod: Some(match &version_data.minecraft_mod { - Some(component) => component.clone(), - None => VersionField::to_minecraft_component(&version_fields) - .map_err(|error| { - CreateError::InvalidInput(error.to_string()) - })?, - }), + minecraft_mod: component, ..Default::default() }, }; diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index e4826e8122..e4e54c9427 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -7,8 +7,14 @@ use crate::database::models::game_item::Game; use crate::database::models::loader_item::{Loader, LoaderMetadata}; use crate::models::exp::minecraft::{Environment, MrpackLoader}; use crate::models::link_platform::LinkPlatform; -use crate::models::minecraft::{LoaderFieldEnumValue, compatibility_value}; +use crate::models::minecraft::{ + legacy_game_version_date, legacy_game_version_name, +}; use actix_web::{HttpResponse, get, web}; +use chrono::{DateTime, Utc}; +use minecraft_meta_model::{ + model::MinecraftVersion, upstream::mojang::VersionType, +}; use strum::IntoEnumIterator; use xredis::RedisPool; @@ -160,6 +166,16 @@ pub async fn loader_list( Ok(HttpResponse::Ok().json(results)) } +#[derive(serde::Deserialize, serde::Serialize)] +pub struct MinecraftTagValue { + pub value: String, + pub ordering: Option, + pub created: DateTime, + #[serde(rename = "type")] + pub ty: Option, + pub major: Option, +} + #[derive(serde::Deserialize, serde::Serialize)] pub struct LoaderFieldsEnumQuery { pub loader_field: String, @@ -189,17 +205,61 @@ pub async fn loader_fields_list( ) -> Result { let query = query.into_inner(); let mut results = match query.loader_field.as_str() { - "game_versions" => { - LoaderFieldEnumValue::minecraft_catalog(&minecraft_meta) - .await - .wrap_internal_err("fetching Minecraft version catalog")? - } - "environment" => Environment::iter() - .map(|value| compatibility_value(2, value.as_str().to_owned())) - .collect(), - "mrpack_loaders" => MrpackLoader::iter() - .map(|value| compatibility_value(3, value.as_str().to_owned())) - .collect(), + "game_versions" => { + let mut conn = minecraft_meta + .0 + .connection() + .await + .wrap_internal_err("acquiring minecraft-meta connection")?; + MinecraftVersion::all() + .exec(&mut conn) + .await + .wrap_internal_err("fetching minecraft-meta game versions")? + .into_iter() + .map(|row| { + let name = row.name.to_string(); + let entry = row.catalog_entry.0; + let ty = match entry.ty { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldAlpha => "alpha", + VersionType::OldBeta => "beta", + }; + let created = DateTime::::from_timestamp( + entry.release_time.as_second(), + entry.release_time.subsec_nanosecond() as u32, + ) + .ok_or_else(|| ApiError::Internal(eyre::eyre!( + "minecraft-meta release timestamp is out of range" + )))?; + Ok(MinecraftTagValue { + value: legacy_game_version_name(&name).to_owned(), + ordering: None, + created: legacy_game_version_date(&name, created), + ty: Some(ty.to_owned()), + major: Some(matches!(entry.ty, VersionType::Release)), + }) + }) + .collect::, ApiError>>()? + } + "environment" => Environment::iter() + .map(|value| MinecraftTagValue { + value: value.as_str().to_owned(), + ordering: None, + created: DateTime::::UNIX_EPOCH, + ty: None, + major: None, + }) + .collect(), + "mrpack_loaders" => MrpackLoader::iter() + .map(|value| MinecraftTagValue { + value: value.as_str().to_owned(), + ordering: None, + created: DateTime::::UNIX_EPOCH, + ty: None, + major: None, + }) + .collect(), _ => { return Err(ApiError::Request(eyre::eyre!( "`{}` is not a valid loader field", diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 853314d834..c4723f6993 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -10,11 +10,9 @@ use crate::database::models::{self, DBOrganization, image_item}; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::exp; +use crate::models::exp::component::ComponentEdit; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; -use crate::models::minecraft::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; @@ -39,9 +37,10 @@ use chrono::Utc; use futures::stream::StreamExt; use hex::ToHex; use itertools::Itertools; +use minecraft_meta_model::model::MinecraftVersionName; use serde::{Deserialize, Serialize}; use sha1::Digest; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use validator::Validate; use xredis::RedisPool; @@ -95,7 +94,7 @@ pub struct InitialVersionData { pub minecraft_mod: Option, // Flattened loader fields - // All other fields are loader-specific VersionFields + // All other fields are Minecraft compatibility fields // These are flattened during serialization #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] @@ -375,21 +374,11 @@ async fn version_create_inner( selected_loaders = Some(loaders.clone()); let loader_ids: Vec = loaders.iter().map(|y| y.id).collect_vec(); - let loader_fields = - LoaderField::get_fields(&loader_ids, &mut *transaction, redis).await?; - let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( - &loader_fields, - &mut *transaction, - redis, - ) - .await?; - let version_fields = try_create_version_fields( - version_id, + let component = create_minecraft_component( &version_create_data.fields, version_create_data.minecraft_mod.as_ref(), - &loader_fields, - &mut loader_field_enum_values, - )?; + &loaders, + ).await?; let dependencies = version_create_data .dependencies @@ -418,11 +407,7 @@ async fn version_create_inner( requested_status: None, ordering: version_create_data.ordering, components: exp::VersionSerial { - minecraft_mod: Some(match &version_create_data.minecraft_mod { - Some(component) => component.clone(), - None => VersionField::to_minecraft_component(&version_fields) - .map_err(|error| CreateError::InvalidInput(error.to_string()))?, - }), + minecraft_mod: component, ..Default::default() }, }); @@ -447,11 +432,8 @@ async fn version_create_inner( let existing_file_names = version.files.iter().map(|x| x.filename.clone()).collect(); - let version_fields = if let Some(component) = &version.components.minecraft_mod { - VersionField::from_minecraft_component(version.version_id, component).await? - } else { - Vec::new() - }; + let game_versions = version.components.minecraft_mod.as_ref() + .map(|component| component.game_versions.clone()).unwrap_or_default(); upload_file( &mut field, @@ -463,7 +445,7 @@ async fn version_create_inner( &content_disposition, version.project_id.into(), version.version_id.into(), - &version_fields, + &game_versions, loaders, version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), @@ -909,16 +891,12 @@ async fn upload_file_to_version_inner( }) .collect(); - let version_fields = - if let Some(component) = &version.components.minecraft_mod { - VersionField::from_minecraft_component( - version_id, - component, - ) - .await? - } else { - Vec::new() - }; + let game_versions = version + .components + .minecraft_mod + .as_ref() + .map(|component| component.game_versions.clone()) + .unwrap_or_default(); upload_file( &mut field, @@ -930,7 +908,7 @@ async fn upload_file_to_version_inner( &content_disposition, project_id, version_id.into(), - &version_fields, + &game_versions, loaders, true, false, @@ -993,7 +971,7 @@ pub async fn upload_file( content_disposition: &actix_web::http::header::ContentDisposition, project_id: ProjectId, version_id: VersionId, - version_fields: &[VersionField], + game_versions: &[MinecraftVersionName], loaders: Vec, ignore_primary: bool, force_primary: bool, @@ -1059,7 +1037,7 @@ pub async fn upload_file( file_extension.to_string(), loaders.clone(), file_type, - version_fields.to_vec(), + game_versions.to_vec(), dependencies, &mut *transaction, redis, @@ -1221,106 +1199,64 @@ pub fn get_name_and_extension( Ok((file_name, file_extension)) } -// Reused functionality between project_creation and version_creation -// Create a list of VersionFields from the fetched data, and check that all mandatory fields are present -pub fn try_create_version_fields( - version_id: VersionId, - submitted_fields: &HashMap, - minecraft_mod: Option<&crate::models::exp::minecraft::ModVersion>, - loader_fields: &[LoaderField], - loader_field_enum_values: &mut HashMap< - crate::models::minecraft::LoaderFieldId, - Vec, - >, -) -> Result, CreateError> { - let typed_names = ["game_versions", "environment", "mrpack_loaders"]; - if minecraft_mod.is_some() - && submitted_fields - .keys() - .any(|key| typed_names.contains(&key.as_str())) - { - return Err(CreateError::InvalidInput( - "cannot combine typed and flattened Minecraft fields".into(), - )); - } - let mut version_fields = if let Some(component) = minecraft_mod { - for (name, count) in [ - ("game_versions", component.game_versions.len()), - ("mrpack_loaders", component.mrpack_loaders.len()), - ] { - if let Some(schema) = - loader_fields.iter().find(|field| field.field == name) - && (schema - .min_val - .is_some_and(|min| (count as i64) < i64::from(min)) - || schema - .max_val - .is_some_and(|max| (count as i64) > i64::from(max))) - { - return Err(CreateError::InvalidInput(format!( - "invalid number of values for `{name}`" - ))); - } - } - let catalog = loader_field_enum_values - .values() - .flatten() - .cloned() - .collect_vec(); - VersionField::from_minecraft_component_with_catalog( - version_id.into(), - component, - &catalog, - ) - .map_err(|error| CreateError::InvalidInput(error.to_string()))? - .into_iter() - .filter(|field| { - loader_fields - .iter() - .any(|schema| schema.field == field.field_name) - }) - .collect() - } else { - Vec::new() - }; - let mut remaining_mandatory_loader_fields = loader_fields - .iter() - .filter(|lf| { - !(lf.optional - || minecraft_mod.is_some() - && typed_names.contains(&lf.field.as_str())) - }) - .map(|lf| lf.field.clone()) - .collect::>(); - for (key, value) in submitted_fields { - let loader_field = loader_fields +pub async fn validate_minecraft_component( + component: &exp::minecraft::ModVersion, + loaders: &[models::loader_item::Loader], +) -> eyre::Result<()> { + eyre::ensure!( + loaders.iter().any(|loader| loader + .supported_games .iter() - .find(|lf| &lf.field == key) - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Loader field '{key}' does not exist for any loaders supplied," - )) - })?; - remaining_mandatory_loader_fields.remove(&loader_field.field); - let enum_variants = loader_field_enum_values - .remove(&loader_field.id) - .unwrap_or_default(); - - let vf: VersionField = VersionField::check_parse( - version_id.into(), - loader_field.clone(), - value.clone(), - enum_variants, - ) - .map_err(CreateError::InvalidInput)?; - version_fields.push(vf); - } + .any(|game| game == "minecraft-java")), + "Minecraft fields do not apply to the supplied loaders" + ); + eyre::ensure!( + !component.game_versions.is_empty(), + "at least one Minecraft game version is required" + ); + eyre::ensure!( + component.mrpack_loaders.is_empty() + || loaders.iter().any(|loader| loader.loader == "mrpack"), + "`mrpack_loaders` requires the `mrpack` loader" + ); + crate::models::minecraft::validate_minecraft_versions( + &component.game_versions, + ) + .await +} - if !remaining_mandatory_loader_fields.is_empty() { - return Err(CreateError::InvalidInput(format!( - "Missing mandatory loader fields: {}", - remaining_mandatory_loader_fields.iter().join(", ") - ))); +pub async fn create_minecraft_component( + fields: &HashMap, + typed: Option<&exp::minecraft::ModVersion>, + loaders: &[models::loader_item::Loader], +) -> Result, CreateError> { + let build = async { + eyre::ensure!( + typed.is_none() || fields.is_empty(), + "cannot combine typed and flattened Minecraft fields" + ); + let applies = loaders.iter().any(|loader| { + loader + .supported_games + .iter() + .any(|game| game == "minecraft-java") + }); + if !applies && typed.is_none() && fields.is_empty() { + return Ok(None); + } + eyre::ensure!( + !fields.contains_key("mrpack_loaders") + || loaders.iter().any(|loader| loader.loader == "mrpack"), + "`mrpack_loaders` requires the `mrpack` loader" + ); + let component = match typed { + Some(component) => component.clone(), + None => crate::models::minecraft::parse_minecraft_fields(fields)? + .create()?, + }; + validate_minecraft_component(&component, loaders).await?; + eyre::Ok(Some(component)) } - Ok(version_fields) + .await; + build.map_err(|error| CreateError::InvalidInput(error.to_string())) } diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 7ae8775a80..791cd3b9b6 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -15,9 +15,6 @@ use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models; use crate::models::ids::VersionId; use crate::models::images::ImageContext; -use crate::models::minecraft::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; use crate::models::pats::Scopes; use crate::models::projects::{ Dependency, FileType, VersionStatus, VersionType, @@ -32,7 +29,6 @@ use crate::util::error::Context; use crate::util::img; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::base62_impl::parse_base62; -use itertools::Itertools; use serde::{Deserialize, Serialize}; use validator::Validate; use xredis::RedisPool; @@ -399,7 +395,7 @@ pub struct EditVersion { pub minecraft_mod: Option, // Flattened loader fields - // All other fields are loader-specific VersionFields + // All other fields are Minecraft compatibility fields // These are flattened during serialization #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] @@ -712,19 +708,25 @@ pub(crate) async fn version_edit_in_transaction( )?; } - if let Some(edit) = new_version.minecraft_mod { + if new_version.minecraft_mod.is_some() + || !new_version.fields.is_empty() + { use crate::models::exp::component::ComponentEdit; - - if new_version.fields.keys().any(|key| { - matches!( - key.as_str(), - "game_versions" | "environment" | "mrpack_loaders" - ) - }) { + if new_version.minecraft_mod.is_some() + && !new_version.fields.is_empty() + { return Err(ApiError::Request(eyre::eyre!( "cannot combine typed and flattened Minecraft fields" ))); } + let edit = match new_version.minecraft_mod { + Some(edit) => edit, + None => crate::models::minecraft::parse_minecraft_fields( + &new_version.fields, + ) + .wrap_request_err("parsing Minecraft fields")?, + }; + let edits_mrpack_loaders = edit.mrpack_loaders.is_some(); let component = if let Some(mut component) = version_item.inner.components.minecraft_mod.clone() { @@ -737,16 +739,40 @@ pub(crate) async fn version_edit_in_transaction( "creating Minecraft version component", )? }; - if component.game_versions.is_empty() { + let all_loaders = database::models::loader_item::Loader::list( + &mut transaction, + &redis, + ) + .await + .wrap_internal_err("fetching loaders")?; + let selected_loaders = all_loaders + .into_iter() + .filter(|loader| { + if let Some(ids) = &new_loader_ids { + ids.contains(&loader.id) + } else { + version_item + .loaders + .iter() + .any(|name| name == &loader.loader) + } + }) + .collect::>(); + if edits_mrpack_loaders + && !selected_loaders + .iter() + .any(|loader| loader.loader == "mrpack") + { return Err(ApiError::Request(eyre::eyre!( - "at least one Minecraft game version is required" + "`mrpack_loaders` requires the `mrpack` loader" ))); } - VersionField::from_minecraft_component(version_id, &component) - .await - .wrap_request_err( - "validating Minecraft version component", - )?; + super::version_creation::validate_minecraft_component( + &component, + &selected_loaders, + ) + .await + .wrap_request_err("validating Minecraft version component")?; sqlx::query!( r#" UPDATE versions @@ -761,114 +787,6 @@ pub(crate) async fn version_edit_in_transaction( .wrap_internal_err("updating Minecraft version component")?; } - if !new_version.fields.is_empty() { - let version_fields_names = new_version - .fields - .keys() - .map(|x| x.to_string()) - .collect::>(); - - let loader_ids = if let Some(loader_ids) = &new_loader_ids { - loader_ids.clone() - } else { - sqlx::query!( - " - SELECT loader_id - FROM loaders_versions - WHERE version_id = $1 - ", - version_id as database::models::ids::DBVersionId, - ) - .fetch_all(&mut *transaction) - .await - .wrap_internal_err( - "fetching version loaders from database", - )? - .into_iter() - .map(|row| database::models::ids::LoaderId(row.loader_id)) - .collect_vec() - }; - - let available_loader_fields = LoaderField::get_fields( - &loader_ids, - &mut *transaction, - &redis, - ) - .await - .wrap_internal_err("fetching loader field from Redis")?; - - let mut incompatible_loader_fields = version_fields_names - .iter() - .filter(|field| { - !available_loader_fields - .iter() - .any(|loader_field| &loader_field.field == *field) - }) - .cloned() - .collect_vec(); - incompatible_loader_fields.sort_unstable(); - - if !incompatible_loader_fields.is_empty() { - let fields = incompatible_loader_fields - .iter() - .map(|field| format!("`{field}`")) - .join(", "); - let message = if incompatible_loader_fields.len() == 1 { - format!( - "loader field {fields} does not exist for any loaders supplied" - ) - } else { - format!( - "loader fields {fields} do not exist for any loaders supplied" - ) - }; - return Err(ApiError::Request(eyre::eyre!(message))); - } - - let loader_fields = available_loader_fields - .into_iter() - .filter(|lf| version_fields_names.contains(&lf.field)) - .collect::>(); - - let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields( - &loader_fields, - &mut *transaction, - &redis, - ) - .await - .wrap_internal_err( - "fetching loader field enum value from Redis", - )?; - - let mut version_fields = Vec::new(); - for (vf_name, vf_value) in new_version.fields { - let loader_field = loader_fields - .iter() - .find(|lf| lf.field == vf_name) - .wrap_request_err_with(|| format!( - "loader field `{vf_name}` does not exist for any loaders supplied" - ))?; - let enum_variants = loader_field_enum_values - .remove(&loader_field.id) - .unwrap_or_default(); - let vf: VersionField = VersionField::check_parse( - version_id, - loader_field.clone(), - vf_value.clone(), - enum_variants, - ) - .map_err(eyre::Report::msg) - .wrap_request_err("parsing version field")?; - version_fields.push(vf); - } - VersionField::insert_many(version_fields, &mut *transaction) - .await - .wrap_internal_err( - "inserting database records for `version_edit_helper`", - )?; - } - if let Some(loader_ids) = new_loader_ids { sqlx::query!( " @@ -1158,20 +1076,19 @@ pub(crate) async fn auto_featured_filters( ) -> Result, ApiError> { let (loaders, game_versions) = futures::future::try_join( database::models::loader_item::Loader::list(&**ro_pool, redis), - crate::models::minecraft::MinecraftGameVersion::list( - None, - Some(true), - &**ro_pool, - redis, - ), + crate::models::minecraft::minecraft_versions(), ) .await .wrap_internal_err("fetching automatic featured filters")?; Ok(game_versions .iter() + .filter(|version| { + crate::models::minecraft::minecraft_version_type(version) + == "release" + }) .flat_map(|game_version| { loaders.iter().map(|loader| { - (game_version.version.clone(), loader.loader.clone()) + (game_version.name.to_string(), loader.loader.clone()) }) }) .collect()) diff --git a/apps/labrinth/src/test/api_v3/tags.rs b/apps/labrinth/src/test/api_v3/tags.rs index 567651a485..70e7120c18 100644 --- a/apps/labrinth/src/test/api_v3/tags.rs +++ b/apps/labrinth/src/test/api_v3/tags.rs @@ -1,8 +1,5 @@ use crate::routes::v3::tags::{GameData, LoaderData}; -use crate::{ - models::minecraft::LoaderFieldEnumValue, - routes::v3::tags::CategoryData, -}; +use crate::routes::v3::tags::{CategoryData, MinecraftTagValue}; use actix_http::StatusCode; use actix_web::{ dev::ServiceResponse, @@ -83,7 +80,7 @@ impl ApiV3 { pub async fn get_loader_field_variants_deserialized( &self, loader_field: &str, - ) -> Vec { + ) -> Vec { let resp = self.get_loader_field_variants(loader_field).await; assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await diff --git a/apps/labrinth/src/util/tags.rs b/apps/labrinth/src/util/tags.rs index 3dad006133..d0e9f04491 100644 --- a/apps/labrinth/src/util/tags.rs +++ b/apps/labrinth/src/util/tags.rs @@ -1,6 +1,6 @@ use crate::database::PgPool; use crate::database::models::loader_item::Loader; -use crate::models::minecraft::MinecraftGameVersion; +use crate::models::minecraft::minecraft_versions; use crate::routes::ApiError; use crate::util::error::Context; use arc_swap::ArcSwapOption; @@ -58,11 +58,11 @@ pub async fn valid_download_tags( .into_iter() .map(|loader| loader.loader) .collect(); - let game_versions = MinecraftGameVersion::list(None, None, pool, redis) + let game_versions = minecraft_versions() .await .wrap_internal_err("failed to fetch game versions")? .into_iter() - .map(|game_version| game_version.version) + .map(|game_version| game_version.name.0) .collect(); let cache = Arc::new(DownloadTagsCache { diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 7f368681f7..d08c04c875 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -1,11 +1,13 @@ use crate::models::ids::ProjectId; -use crate::models::minecraft::MinecraftGameVersion; +use crate::models::minecraft::{minecraft_version_date, minecraft_versions}; use crate::routes::ApiError; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use crate::{database::PgPool, env::ENV}; use ariadne::ids::base62_impl::to_base62; use chrono::{DateTime, Utc}; +use minecraft_meta_model::model::MinecraftVersion; +use minecraft_meta_model::upstream::mojang::VersionType; use serde::Serialize; use xredis::RedisPool; @@ -109,12 +111,9 @@ async fn get_webhook_metadata( } }; - let all_game_versions = - MinecraftGameVersion::list(None, None, pool, redis) - .await - .wrap_internal_err( - "fetching minecraft game version from Redis", - )?; + let all_game_versions = minecraft_versions() + .await + .wrap_internal_err("fetching minecraft version catalog")?; let supported_names = project .minecraft_mod_versions @@ -126,9 +125,7 @@ async fn get_webhook_metadata( .collect::>(); let versions = all_game_versions .iter() - .filter(|version| { - supported_names.contains(version.version.as_str()) - }) + .filter(|version| supported_names.contains(version.name.0.as_str())) .cloned() .collect(); @@ -453,18 +450,18 @@ pub async fn send_discord_webhook( } fn get_gv_range( - mut game_versions: Vec, - mut all_game_versions: Vec, + mut game_versions: Vec, + mut all_game_versions: Vec, ) -> Vec { // both -> least to greatest - game_versions.sort_by_key(|a| a.created); - game_versions.dedup_by(|a, b| a.version == b.version); + game_versions.sort_by_key(minecraft_version_date); + game_versions.dedup_by(|a, b| a.name.0 == b.name.0); - all_game_versions.sort_by_key(|a| a.created); + all_game_versions.sort_by_key(minecraft_version_date); let all_releases = all_game_versions .iter() - .filter(|x| &*x.type_ == "release") + .filter(|x| x.catalog_entry.0.ty == VersionType::Release) .cloned() .collect::>(); @@ -474,15 +471,15 @@ fn get_gv_range( const MAX_VALUE: usize = 1000000; for (i, current_version) in game_versions.iter().enumerate() { - let current_version = ¤t_version.version; + let current_version = ¤t_version.name.0; let index = all_game_versions .iter() - .position(|x| &*x.version == current_version) + .position(|x| &*x.name.0 == current_version) .unwrap_or(MAX_VALUE); let release_index = all_releases .iter() - .position(|x| &*x.version == current_version) + .position(|x| &*x.name.0 == current_version) .unwrap_or(MAX_VALUE); if i == 0 { @@ -496,8 +493,10 @@ fn get_gv_range( || (release_index as i32) - (interval_base[interval_base.len() - 1][2] as i32) == 1) - && (all_game_versions[interval_base[0][1]].type_ == "release" - || all_game_versions[index].type_ != "release") + && (all_game_versions[interval_base[0][1]].catalog_entry.0.ty + == VersionType::Release + || all_game_versions[index].catalog_entry.0.ty + != VersionType::Release) { if intervals[current_interval].get(1).is_some() { intervals[current_interval][1] = @@ -523,21 +522,23 @@ fn get_gv_range( let mut last_snapshot: Option = None; for j in ((interval[0][1] + 1)..=interval[1][1]).rev() { - if all_game_versions[j].type_ == "release" { + if all_game_versions[j].catalog_entry.0.ty + == VersionType::Release + { new_intervals.push(vec![ interval[0].clone(), vec![ game_versions .iter() .position(|x| { - x.version == all_game_versions[j].version + x.name.0 == all_game_versions[j].name.0 }) .unwrap_or(MAX_VALUE), j, all_releases .iter() .position(|x| { - x.version == all_game_versions[j].version + x.name.0 == all_game_versions[j].name.0 }) .unwrap_or(MAX_VALUE), ], @@ -550,10 +551,11 @@ fn get_gv_range( game_versions .iter() .position(|x| { - x.version + x.name.0 == all_game_versions [last_snapshot] - .version + .name + .0 }) .unwrap_or(MAX_VALUE), last_snapshot, @@ -582,11 +584,11 @@ fn get_gv_range( if interval.len() == 2 { output.push(format!( "{}–{}", - &game_versions[interval[0][0]].version, - &game_versions[interval[1][0]].version + &game_versions[interval[0][0]].name.0, + &game_versions[interval[1][0]].name.0 )) } else { - output.push(game_versions[interval[0][0]].version.clone()) + output.push(game_versions[interval[0][0]].name.0.clone()) } } diff --git a/apps/labrinth/src/validate/fabric.rs b/apps/labrinth/src/validate/fabric.rs index f208680e50..b03fcc2b22 100644 --- a/apps/labrinth/src/validate/fabric.rs +++ b/apps/labrinth/src/validate/fabric.rs @@ -1,10 +1,11 @@ -use crate::models::minecraft::MinecraftGameVersion; +use crate::models::minecraft::minecraft_version_date; use crate::models::projects::Loader; use crate::validate::{ SupportedGameVersions, ValidationError, ValidationResult, validate_pack_formats, }; use eyre::eyre; +use minecraft_meta_model::model::MinecraftVersion; use std::io::Cursor; use zip::ZipArchive; @@ -78,14 +79,14 @@ fn has_bta_loader_dependency(requirement: &serde_json::Value) -> bool { pub(super) fn validate_game_versions( loaders: &[Loader], - game_versions: &[MinecraftGameVersion], - all_game_versions: &[MinecraftGameVersion], + game_versions: &[MinecraftVersion], + all_game_versions: &[MinecraftVersion], ) -> Result<(), ValidationError> { let release_date = |name: &str| { all_game_versions .iter() - .find(|version| version.version == name) - .map(|version| version.created) + .find(|version| version.name.0 == name) + .map(minecraft_version_date) .ok_or_else(|| { eyre!( "missing minecraft game version `{name}` required for loader validation" @@ -98,25 +99,25 @@ pub(super) fn validate_game_versions( "fabric" => { let minimum = release_date("18w43b")?; ( - game_versions - .iter() - .all(|version| version.created >= minimum), + game_versions.iter().all(|version| { + minecraft_version_date(version) >= minimum + }), "`18w43b` or later", ) } "legacy-fabric" => { let supported = release_date("1.3")?..=release_date("1.13.2")?; ( - game_versions - .iter() - .all(|version| supported.contains(&version.created)), + game_versions.iter().all(|version| { + supported.contains(&minecraft_version_date(version)) + }), "between `1.3` and `1.13.2`, inclusive", ) } "babric" | "bta-babric" => ( game_versions .iter() - .all(|version| version.version == "b1.7.3"), + .all(|version| version.name.0 == "b1.7.3"), "`b1.7.3` only", ), _ => continue, diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index 4e1f3d0b1d..986c922fe4 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -2,8 +2,7 @@ use crate::database::PgTransaction; use crate::database::models::loader_item::Loader as DBLoader; use crate::database::models::version_item::DependencyBuilder; use crate::models::ids::ProjectId; -use crate::models::minecraft::MinecraftGameVersion; -use crate::models::minecraft::VersionField; +use crate::models::minecraft::{minecraft_version_date, minecraft_versions}; use crate::models::pack::PackFormat; use crate::models::projects::{DependencyType, FileType, Loader}; use crate::validate::datapack::DataPackValidator; @@ -22,6 +21,7 @@ use crate::validate::shader::{ }; use bytes::Bytes; use chrono::{DateTime, Utc}; +use minecraft_meta_model::model::{MinecraftVersion, MinecraftVersionName}; use std::io::{self, Cursor}; use std::mem; use std::sync::LazyLock; @@ -90,7 +90,7 @@ pub enum SupportedGameVersions { All, PastDate(DateTime), Range(DateTime, DateTime), - Custom(Vec), + Custom(Vec), } pub enum MaybeProtectedZipFile { @@ -195,18 +195,15 @@ pub async fn validate_file( file_extension: String, loaders: Vec, file_type: Option, - version_fields: Vec, + game_versions: Vec, dependencies: &[DependencyBuilder], transaction: &mut PgTransaction<'_>, redis: &RedisPool, ) -> Result { - let component = VersionField::to_minecraft_component(&version_fields)?; - let all_game_versions = - MinecraftGameVersion::list(None, None, &mut *transaction, redis) - .await?; - let game_versions = component.game_versions.iter().map(|name| { + let all_game_versions = minecraft_versions().await?; + let game_versions = game_versions.iter().map(|name| { let name = crate::models::minecraft::legacy_game_version_name(&name.0); - all_game_versions.iter().find(|version| version.version == name) + all_game_versions.iter().find(|version| version.name.0 == name) .cloned() .ok_or_else(|| eyre::eyre!("missing minecraft game version `{name}` required for validation")) }).collect::, _>>()?; @@ -319,8 +316,8 @@ async fn validate_minecraft_file( data: Bytes, file_extension: String, loaders: Vec, - game_versions: Vec, - all_game_versions: Vec, + game_versions: Vec, + all_game_versions: Vec, file_type: Option, ) -> Result { actix_web::web::block(move || { @@ -417,8 +414,8 @@ async fn validate_minecraft_file( // Write tests for this fn game_version_supported( - game_versions: &[MinecraftGameVersion], - all_game_versions: &[MinecraftGameVersion], + game_versions: &[MinecraftVersion], + all_game_versions: &[MinecraftVersion], supported_game_versions: SupportedGameVersions, ) -> bool { match supported_game_versions { @@ -427,24 +424,27 @@ fn game_version_supported( game_versions.iter().any(|x| { all_game_versions .iter() - .find(|y| y.version == x.version) - .is_some_and(|x| x.created > date) + .find(|y| y.name == x.name) + .is_some_and(|x| minecraft_version_date(x) > date) }) } SupportedGameVersions::Range(before, after) => { game_versions.iter().any(|x| { all_game_versions .iter() - .find(|y| y.version == x.version) - .is_some_and(|x| x.created > before && x.created < after) + .find(|y| y.name == x.name) + .is_some_and(|x| { + minecraft_version_date(x) > before + && minecraft_version_date(x) < after + }) }) } SupportedGameVersions::Custom(versions) => { - let version_ids = - versions.iter().map(|gv| gv.id).collect::>(); - let game_version_ids: Vec<_> = - game_versions.iter().map(|gv| gv.id).collect::>(); - version_ids.iter().any(|x| game_version_ids.contains(x)) + versions.iter().any(|version| { + game_versions + .iter() + .any(|selected| selected.name == version.name) + }) } } } diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index b9ff426c4b..f772f2189c 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -12,7 +12,7 @@ use labrinth::database::models::{ user_limits::DBUserLimits, version_item::{VERSIONS_NAMESPACE, VersionQueryResult}, }; -use labrinth::models::{minecraft::MinecraftGameVersion, v3}; +use labrinth::models::v3; use serde_json::json; use xredis::RedisValue; @@ -717,69 +717,3 @@ async fn test_multi_get_redis_cache() { ) .await; } - -#[actix_rt::test] -async fn minecraft_game_version_update() { - // We simulate adding a Minecraft game version, to ensure other data doesn't get overwritten - // This is basically a test for the insertion/concatenation query - // This doesn't use a route (as this behaviour isn't exposed via a route, but a scheduled URL call) - // We just interact with the labrinth functions directly - with_test_environment(None, |test_env: TestEnvironment| async move { - let api = &test_env.api; - - // First, get a list of all gameversions - let game_versions = api - .get_loader_field_variants_deserialized("game_versions") - .await; - - // A couple specific checks- in the dummy data, all game versions are marked as major=false except 1.20.5 - let name_to_major = game_versions - .iter() - .map(|x| (x.value.clone(), x.major.unwrap())) - .collect::>(); - for (name, major) in name_to_major { - if name == "1.20.5" { - assert!(major); - } else { - assert!(!major); - } - } - - // Now, we add a new game version, directly to the db - let pool = test_env.db.pool.clone(); - let redis = test_env.db.redis_pool.clone(); - MinecraftGameVersion::builder() - .version("1.20.6") - .unwrap() - .version_type("release") - .unwrap() - .created( - // now - &chrono::Utc::now(), - ) - .insert(&pool, &redis) - .await - .unwrap(); - - // Check again - let game_versions = api - .get_loader_field_variants_deserialized("game_versions") - .await; - - let name_to_major = game_versions - .iter() - .map(|x| (x.value.clone(), x.major.unwrap())) - .collect::>(); - // Confirm that the new version is there - assert!(name_to_major.contains_key("1.20.6")); - // Confirm metadata is unaltered - for (name, major) in name_to_major { - if name == "1.20.5" { - assert!(major); - } else { - assert!(!major); - } - } - }) - .await -} From aae62302939994e97b862f91e3b30dcd49cc1f9f Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:07:26 +0100 Subject: [PATCH 07/13] use MinecraftMeta instead of fetching daedalus --- apps/labrinth/src/lib.rs | 8 +- apps/labrinth/src/main.rs | 15 ++- apps/labrinth/src/models/minecraft.rs | 70 +------------ apps/labrinth/src/routes/internal/billing.rs | 8 +- .../src/routes/internal/delphi/mod.rs | 13 ++- .../src/routes/v2/project_creation.rs | 4 + apps/labrinth/src/routes/v2/projects.rs | 4 + apps/labrinth/src/routes/v2/tags.rs | 22 ++--- .../src/routes/v2/version_creation.rs | 5 + apps/labrinth/src/routes/v2/versions.rs | 26 +++-- .../routes/v3/analytics_get/facets/fixed.rs | 4 +- .../src/routes/v3/analytics_get/facets/mod.rs | 4 +- .../src/routes/v3/project_creation.rs | 38 ++++--- apps/labrinth/src/routes/v3/projects/mod.rs | 7 ++ apps/labrinth/src/routes/v3/tags.rs | 27 +++-- .../src/routes/v3/version_creation.rs | 61 ++++++++---- apps/labrinth/src/routes/v3/versions.rs | 42 ++++++-- apps/labrinth/src/test/mod.rs | 15 +-- apps/labrinth/src/util/minecraft_meta.rs | 98 +++++++++++++++++++ apps/labrinth/src/util/mod.rs | 3 + apps/labrinth/src/util/tags.rs | 15 +-- apps/labrinth/src/util/webhook.rs | 28 ++++-- apps/labrinth/src/validate/mod.rs | 17 +++- 23 files changed, 341 insertions(+), 193 deletions(-) create mode 100644 apps/labrinth/src/util/minecraft_meta.rs diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index e0c569f9ac..cd9201cd1b 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -56,7 +56,7 @@ pub struct Pepper { pub struct LabrinthConfig { pub pool: PgPool, pub ro_pool: ReadOnlyPgPool, - pub minecraft_meta_db: web::Data, + pub minecraft_meta: web::Data, pub redis_pool: RedisPool, pub clickhouse: Client, pub file_host: web::Data, @@ -82,7 +82,7 @@ pub struct LabrinthConfig { pub fn app_setup( pool: PgPool, ro_pool: ReadOnlyPgPool, - minecraft_meta_db: web::Data, + minecraft_meta: web::Data, redis_pool: RedisPool, search_backend: actix_web::web::Data, clickhouse: &mut Client, @@ -236,7 +236,7 @@ pub fn app_setup( LabrinthConfig { pool, ro_pool, - minecraft_meta_db, + minecraft_meta, redis_pool, clickhouse: clickhouse.clone(), file_host, @@ -289,7 +289,7 @@ pub fn app_data_config( .app_data(web::Data::new(labrinth_config.redis_pool.clone())) .app_data(web::Data::new(labrinth_config.pool.clone())) .app_data(web::Data::new(labrinth_config.ro_pool.clone())) - .app_data(labrinth_config.minecraft_meta_db.clone()) + .app_data(labrinth_config.minecraft_meta.clone()) .app_data(labrinth_config.file_host.clone()) .app_data(web::Data::from( labrinth_config.search_state.backend.clone(), diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 6ac0bc74b7..ad4bb37942 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -125,15 +125,14 @@ async fn app() -> std::io::Result<()> { let (pool, ro_pool) = database::connect_all() .await .expect("Database connection failed"); - let minecraft_meta_db = actix_web::web::Data::new( - database::MinecraftMetaDb::connect() + let minecraft_meta_db = database::MinecraftMetaDb::connect() + .await + .expect("Minecraft metadata database connection failed"); + let minecraft_meta = web::Data::new( + labrinth::util::MinecraftMeta::new(minecraft_meta_db) .await - .expect("Minecraft metadata database connection failed"), + .expect("Minecraft metadata initialization failed"), ); - labrinth::models::minecraft::register_minecraft_metadata( - minecraft_meta_db.clone(), - ) - .expect("Minecraft metadata service registration failed"); // Redis connector let redis_pool = redis::from_env("").await; @@ -236,7 +235,7 @@ async fn app() -> std::io::Result<()> { let labrinth_config = labrinth::app_setup( pool.clone(), ro_pool.clone(), - minecraft_meta_db, + minecraft_meta, redis_pool.clone(), search_backend.clone(), &mut clickhouse, diff --git a/apps/labrinth/src/models/minecraft.rs b/apps/labrinth/src/models/minecraft.rs index 7144814362..1b677aac71 100644 --- a/apps/labrinth/src/models/minecraft.rs +++ b/apps/labrinth/src/models/minecraft.rs @@ -1,65 +1,18 @@ //! Minecraft catalog access and compatibility parsing for legacy API payloads. -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, LazyLock}; +use std::collections::HashMap; +use std::sync::LazyLock; use chrono::{DateTime, Utc}; -use eyre::{Result, WrapErr, eyre}; +use eyre::{Result, eyre}; use minecraft_meta_model::{ model::{MinecraftVersion, MinecraftVersionName}, upstream::mojang::VersionType, }; use serde_json::Value; -use tokio::sync::OnceCell; -use crate::database::MinecraftMetaDb; use crate::models::exp::minecraft::{Environment, ModVersionEdit, MrpackLoader}; -static MINECRAFT_METADATA: LazyLock>> = - LazyLock::new(OnceCell::new); - -pub fn register_minecraft_metadata( - metadata: actix_web::web::Data, -) -> Result<()> { - MINECRAFT_METADATA.set(metadata.into_inner()).map_err(|_| { - eyre!("minecraft-meta service is already initialized") - }) -} - -async fn minecraft_metadata() -> Result<&'static MinecraftMetaDb> { - Ok(MINECRAFT_METADATA - .get_or_try_init(|| async { - MinecraftMetaDb::connect().await.map(Arc::new) - }) - .await? - .as_ref()) -} - -/// Returns catalog records with the names historically exposed by Labrinth. -pub async fn minecraft_versions() -> Result> { - let mut connection = minecraft_metadata() - .await? - .0 - .connection() - .await - .wrap_err("acquiring minecraft-meta connection")?; - let mut versions = MinecraftVersion::all() - .exec(&mut connection) - .await - .wrap_err("fetching Minecraft game versions")?; - for version in &mut versions { - version.name = MinecraftVersionName( - legacy_game_version_name(&version.name.0).to_owned(), - ); - } - versions.sort_by(|a, b| { - minecraft_version_date(b) - .cmp(&minecraft_version_date(a)) - .then_with(|| a.name.0.cmp(&b.name.0)) - }); - Ok(versions) -} - pub fn minecraft_version_date(version: &MinecraftVersion) -> DateTime { let timestamp = version.catalog_entry.0.release_time; let date = DateTime::from_timestamp( @@ -79,23 +32,6 @@ pub fn minecraft_version_type(version: &MinecraftVersion) -> &'static str { } } -pub async fn validate_minecraft_versions( - names: &[MinecraftVersionName], -) -> Result<()> { - if names.is_empty() { - return Ok(()); - } - let versions = minecraft_versions().await?; - let known = versions.iter().map(|version| &version.name).collect::>(); - for name in names { - eyre::ensure!( - known.contains(name), - "Minecraft version `{name}` is absent from minecraft-meta" - ); - } - Ok(()) -} - /// Parses only the three flattened Minecraft fields retained by the v3 API. pub fn parse_minecraft_fields( fields: &HashMap, diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 6e4cac49f7..e9dd953f6c 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -22,6 +22,7 @@ use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::anrok; +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; @@ -1798,6 +1799,7 @@ pub async fn stripe_webhook( pool: web::Data, redis: web::Data, stripe_client: web::Data, + minecraft_meta: web::Data, ) -> Result { let stripe_signature = req .headers() @@ -2311,8 +2313,10 @@ pub async fn stripe_webhook( ) } else { // Create a server with the latest version of Minecraft - let minecraft_versions = crate::models::minecraft::minecraft_versions() - .await.wrap_internal_err("fetching minecraft version catalog")?; + let minecraft_versions = minecraft_meta + .minecraft_versions() + .await + .wrap_internal_err("fetching minecraft version catalog")?; ( None, diff --git a/apps/labrinth/src/routes/internal/delphi/mod.rs b/apps/labrinth/src/routes/internal/delphi/mod.rs index 38204d38f7..760b2f9ea4 100644 --- a/apps/labrinth/src/routes/internal/delphi/mod.rs +++ b/apps/labrinth/src/routes/internal/delphi/mod.rs @@ -1,3 +1,4 @@ +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use std::{collections::HashMap, fmt::Write, time::Instant}; use xredis::RedisPool; @@ -111,6 +112,7 @@ struct DelphiReport { impl DelphiReport { async fn send_to_slack( &self, + minecraft_meta: &MinecraftMeta, pool: &PgPool, redis: &RedisPool, ) -> Result<(), ApiError> { @@ -137,6 +139,7 @@ impl DelphiReport { } crate::util::webhook::send_slack_project_webhook( + minecraft_meta, self.project_id, pool, redis, @@ -160,6 +163,7 @@ pub struct DelphiRunParameters { )] #[post("/ingest", guard = "admin_key_guard")] pub async fn ingest_report( + minecraft_meta: web::Data, pool: web::Data, redis: web::Data, web::Json(report): web::Json, @@ -175,7 +179,8 @@ pub async fn ingest_report( ) })?; - ingest_report_deserialized(pool, redis, report).await + ingest_report_deserialized(minecraft_meta.clone(), pool, redis, report) + .await } #[tracing::instrument( @@ -190,6 +195,7 @@ pub async fn ingest_report( ) )] async fn ingest_report_deserialized( + minecraft_meta: web::Data, pool: web::Data, redis: web::Data, report: DelphiReport, @@ -319,7 +325,10 @@ async fn ingest_report_deserialized( .wrap_internal_err("failed to commit Delphi ingest transaction")?; if !clean { - report.send_to_slack(&pool, &redis).await.ok(); + report + .send_to_slack(&minecraft_meta, &pool, &redis) + .await + .ok(); } Ok(()) diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 7045f1acf9..04d08dde79 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -1,3 +1,4 @@ +use actix_web::web; use crate::database::PgPool; use crate::database::models::version_item; use crate::file_hosting::FileHost; @@ -12,6 +13,7 @@ use crate::routes::v3::project_creation::default_project_type; use crate::routes::v3::project_creation::{CreateError, NewGalleryItem}; use crate::routes::{v2_reroute, v3}; use crate::search::SearchState; +use crate::util::MinecraftMeta; use crate::util::kafka::KafkaClientState; use actix_multipart::Multipart; use actix_web::web::Data; @@ -154,6 +156,7 @@ struct ProjectCreateData { )] #[post("/project")] pub async fn project_create( + minecraft_meta: web::Data, req: HttpRequest, payload: Multipart, client: Data, @@ -302,6 +305,7 @@ pub async fn project_create( // Call V3 project creation let response = v3::project_creation::project_create_internal( + minecraft_meta.clone(), req, payload, client.clone(), diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 1a6e036fef..e91fcc468d 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -16,6 +16,7 @@ use crate::queue::session::AuthQueue; use crate::routes::v3::projects::ProjectIds; use crate::routes::{ApiError, v2_reroute, v3}; use crate::search::{SearchBackend, SearchRequest, SearchState}; +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; @@ -624,6 +625,7 @@ pub struct EditProject { #[patch("/{id}")] #[allow(clippy::too_many_arguments)] pub async fn project_edit( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -755,6 +757,7 @@ pub async fn project_edit( // This returns 204 or failure so we don't need to do anything with it let project_id = info.clone().0; let mut response = v3::projects::project_edit_internal( + minecraft_meta.clone(), req.clone(), info, pool.clone(), @@ -809,6 +812,7 @@ pub async fn project_edit( }; response = v3::versions::version_edit_helper( + minecraft_meta.clone(), req.clone(), (version.inner.id.into(),), pool.clone(), diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 87cf30e658..8f5bc8f5fd 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -1,10 +1,11 @@ use crate::util::error::{ApiContext as _, Context as _}; +use crate::util::MinecraftMeta; use super::ApiError; use crate::models::minecraft::{ legacy_game_version_date, legacy_game_version_name, }; -use crate::database::{MinecraftMetaDb, PgPool}; +use crate::database::PgPool; use crate::models::v2::projects::LegacySideType; use crate::routes::v2_reroute::capitalize_first; use crate::routes::v3::tags::LinkPlatformQueryData; @@ -12,9 +13,7 @@ use crate::routes::{v2_reroute, v3}; use actix_web::{HttpResponse, get, web}; use chrono::{DateTime, Utc}; use itertools::Itertools; -use minecraft_meta_model::{ - model::MinecraftVersion, upstream::mojang::VersionType, -}; +use minecraft_meta_model::upstream::mojang::VersionType; use xredis::RedisPool; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -188,22 +187,17 @@ pub struct GameVersionQuery { )] #[get("/game_version")] pub async fn game_version_list( - minecraft_meta_db: web::Data, + minecraft_meta: web::Data, query: web::Query, ) -> Result>, ApiError> { - let mut conn = minecraft_meta_db - .0 - .connection() - .await - .wrap_internal_err("acquiring minecraft-meta connection")?; - let versions = MinecraftVersion::all() - .exec(&mut conn) + let versions = minecraft_meta + .minecraft_versions() .await .wrap_internal_err("fetching minecraft-meta game versions")?; let mut versions = versions - .into_iter() + .iter() .map(|row| { - let entry = row.catalog_entry.0; + let entry = &row.catalog_entry.0; let version_type = match entry.ty { VersionType::Release => "release", VersionType::Snapshot => "snapshot", diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 8196c3b916..ad5fa5d50f 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -13,6 +13,7 @@ use crate::routes::v3::project_creation::CreateError; use crate::routes::v3::version_creation; use crate::routes::{v2_reroute, v3}; use crate::search::SearchState; +use crate::util::MinecraftMeta; use crate::util::kafka::KafkaClientState; use actix_multipart::Multipart; use actix_web::http::header::ContentDisposition; @@ -119,6 +120,7 @@ pub(super) fn environment_from_sides( )] #[post("/version")] pub async fn version_create( + minecraft_meta: web::Data, req: HttpRequest, payload: Multipart, client: Data, @@ -253,6 +255,7 @@ pub async fn version_create( // Call V3 project creation let response = v3::version_creation::version_create( + minecraft_meta.clone(), req, payload, client.clone(), @@ -331,6 +334,7 @@ async fn get_example_environment( )] #[post("/{version_id}/file")] pub async fn upload_file_to_version( + minecraft_meta: web::Data, req: HttpRequest, url_data: web::Path<(VersionId,)>, payload: Multipart, @@ -343,6 +347,7 @@ pub async fn upload_file_to_version( ) -> Result { // Returns NoContent, so no need to convert to V2 let response = v3::version_creation::upload_file_to_version( + minecraft_meta.clone(), req, url_data, payload, diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index d78a918adf..c97a0ed7c7 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -1,3 +1,4 @@ +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use std::collections::HashMap; @@ -130,6 +131,7 @@ fn default_true() -> bool { )] #[get("/version")] pub async fn version_list( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query, @@ -154,6 +156,7 @@ pub async fn version_list( let filters = filters.into_v3(); let response = v3::versions::version_list_internal( + minecraft_meta.clone(), req, info, web::Query(filters), @@ -276,6 +279,7 @@ pub struct VersionsFilters { )] #[get("/versions")] pub async fn versions_get( + minecraft_meta: web::Data, req: HttpRequest, web::Query(query): web::Query, pool: web::Data, @@ -434,19 +438,25 @@ pub async fn versions_get( let mut filters = filters.into_v3(); let limit = filters.limit.take(); let offset = filters.offset.take(); - let auto_featured = if filters.featured == Some(true) { - Some( - v3::versions::auto_featured_filters(&ro_pool, &redis) + let auto_featured: Option> = + if filters.featured == Some(true) { + Some( + v3::versions::auto_featured_filters( + minecraft_meta.clone(), + &ro_pool, + &redis, + ) .await?, - ) - } else { - None - }; + ) + } else { + None + }; let mut response = Vec::new(); for mut versions in grouped.into_values() { versions.sort_by_key(|version| version.inner.id.0); response.extend( v3::versions::filter_project_versions( + minecraft_meta.clone(), versions, &filters, &ro_pool, @@ -624,6 +634,7 @@ pub struct EditVersionFileType { )] #[patch("/{id}")] pub async fn version_edit( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(VersionId,)>, pool: web::Data, @@ -726,6 +737,7 @@ pub async fn version_edit( }; let response = v3::versions::version_edit_helper( + minecraft_meta.clone(), req, info.into_inner(), pool, diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs index 02a37254bb..422b31b72f 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs @@ -4,6 +4,7 @@ use super::{ ProjectViewsFacets, }; use crate::util::error::ApiContext as _; +use crate::util::MinecraftMeta; use crate::{ database::PgPool, models::v3::analytics::DownloadReason, routes::ApiError, util::tags::valid_download_tags, @@ -13,8 +14,9 @@ use xredis::RedisPool; pub async fn fetch( pool: &PgPool, redis: &RedisPool, + minecraft_meta: &MinecraftMeta, ) -> Result { - let tags = valid_download_tags(pool, redis) + let tags = valid_download_tags(pool, redis, minecraft_meta) .await .wrap_api_err("executing `valid_download_tags`")?; let mut loaders = tags.loaders.iter().cloned().collect::>(); diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs index 4fd0c498e3..4d8564c845 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs @@ -1,5 +1,6 @@ use crate::util::error::ApiContext as _; use crate::util::error::Context as _; +use crate::util::MinecraftMeta; use xredis::RedisPool; mod fixed; @@ -72,6 +73,7 @@ pub async fn fetch_facets( pool: web::Data, redis: web::Data, session_queue: web::Data, + minecraft_meta: web::Data, ) -> Result, ApiError> { get_user_from_headers( &http_req, @@ -83,7 +85,7 @@ pub async fn fetch_facets( .await .wrap_auth_err("authenticating API request")?; - let facets = fixed::fetch(&pool, &redis) + let facets = fixed::fetch(&pool, &redis, &minecraft_meta) .await .wrap_api_err("executing `fixed::fetch`")?; diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index eebe1b84b3..19c18a57d1 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -24,6 +24,7 @@ use crate::models::users::User; use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::search::SearchState; +use crate::util::MinecraftMeta; use crate::util::guards::admin_key_guard; use crate::util::img::upload_image_optimized; use crate::util::kafka::KafkaClientState; @@ -314,6 +315,7 @@ pub async fn undo_uploads( )] #[post("")] pub async fn project_create( + minecraft_meta: web::Data, req: HttpRequest, payload: Multipart, client: Data, @@ -324,6 +326,7 @@ pub async fn project_create( search_state: Data, ) -> Result { project_create_internal( + minecraft_meta.clone(), req, payload, client, @@ -337,6 +340,7 @@ pub async fn project_create( } pub async fn project_create_internal( + minecraft_meta: web::Data, req: HttpRequest, mut payload: Multipart, client: Data, @@ -364,6 +368,7 @@ pub async fn project_create_internal( let result = async { let response = project_create_inner( + minecraft_meta.clone(), &mut payload, &mut transaction, &**file_host, @@ -422,6 +427,7 @@ pub async fn project_create_internal( )] #[post("/{id}", guard = "admin_key_guard")] pub async fn project_create_with_id( + minecraft_meta: web::Data, req: HttpRequest, mut payload: Multipart, client: Data, @@ -449,6 +455,7 @@ pub async fn project_create_with_id( let result = async { let response = project_create_inner( + minecraft_meta.clone(), &mut payload, &mut transaction, &**file_host, @@ -527,6 +534,7 @@ Project Creation Steps: #[allow(clippy::too_many_arguments)] async fn project_create_inner( + minecraft_meta: web::Data, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, file_host: &dyn FileHost, @@ -698,6 +706,7 @@ async fn project_create_inner( } versions.push( create_initial_version( + minecraft_meta.clone(), data, project_id, current_user.id, @@ -820,6 +829,7 @@ async fn project_create_inner( .map(|component| component.game_versions.clone()).unwrap_or_default(); // Upload the new jar file super::version_creation::upload_file( + minecraft_meta.clone(), &mut field, file_host, version_data.file_parts.len(), @@ -1077,19 +1087,19 @@ async fn project_create_inner( .flat_map(|v| v.loaders.clone()) .unique() .collect::>(); - let (project_types, games) = Loader::list(&mut *transaction, redis) - .await? - .into_iter() - .fold( - (Vec::new(), Vec::new()), - |(mut project_types, mut games), loader| { - if loaders.contains(&loader.id) { - project_types.extend(loader.supported_project_types); - games.extend(loader.supported_games); - } - (project_types, games) - }, - ); + let project_types = Loader::list(&mut *transaction, redis) + .await? + .into_iter() + .filter(|loader| loaders.contains(&loader.id)) + .flat_map(|loader| loader.supported_project_types) + .collect(); + let games = if loaders.is_empty() + || project_builder.components.minecraft_server.is_some() + { + Vec::new() + } else { + vec!["minecraft-java".to_owned()] + }; let response = crate::models::projects::Project { id: project_id, @@ -1147,6 +1157,7 @@ async fn project_create_inner( } async fn create_initial_version( + minecraft_meta: web::Data, version_data: &InitialVersionData, project_id: ProjectId, author: UserId, @@ -1186,6 +1197,7 @@ async fn create_initial_version( .cloned() .collect::>(); let component = create_minecraft_component( + minecraft_meta.clone(), &version_data.fields, version_data.minecraft_mod.as_ref(), &selected_loaders, diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index e61c949815..1fa660682c 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1,3 +1,4 @@ +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use std::any::type_name; use std::cmp::Reverse; @@ -460,6 +461,7 @@ pub struct EditProject { )] #[patch("/{id}")] pub async fn project_edit( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -482,6 +484,7 @@ pub async fn project_edit( } project_edit_internal( + minecraft_meta.clone(), req, info, pool, @@ -495,6 +498,7 @@ pub async fn project_edit( } pub async fn project_edit_internal( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -748,6 +752,7 @@ pub async fn project_edit_internal( && project_item.inner.components.minecraft_server.is_none() { crate::util::webhook::send_discord_webhook( + &minecraft_meta, project_item.inner.id.into(), &pool, &redis, @@ -774,6 +779,7 @@ pub async fn project_edit_internal( if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() { crate::util::webhook::send_slack_project_webhook( + &minecraft_meta, project_item.inner.id.into(), &pool, &redis, @@ -1286,6 +1292,7 @@ pub async fn project_edit_internal( .await .wrap_internal_err("starting version edit savepoint")?; match super::versions::version_edit_in_transaction( + minecraft_meta.clone(), req.clone(), (VersionId::from(version.inner.id),), pool.clone(), diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index e4e54c9427..5034a27ed0 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -1,4 +1,5 @@ use crate::util::error::Context as _; +use crate::util::MinecraftMeta; use std::collections::HashMap; use super::ApiError; @@ -12,13 +13,11 @@ use crate::models::minecraft::{ }; use actix_web::{HttpResponse, get, web}; use chrono::{DateTime, Utc}; -use minecraft_meta_model::{ - model::MinecraftVersion, upstream::mojang::VersionType, -}; +use minecraft_meta_model::upstream::mojang::VersionType; use strum::IntoEnumIterator; use xredis::RedisPool; -use crate::database::{MinecraftMetaDb, PgPool}; +use crate::database::PgPool; use serde_json::Value; @@ -193,32 +192,28 @@ pub struct LoaderFieldsEnumQuery { )] #[get("/loader_field")] pub async fn loader_fields_list_route( - minecraft_meta: web::Data, + minecraft_meta: web::Data, web::Query(query): web::Query, ) -> Result { loader_fields_list(minecraft_meta, web::Query(query)).await } pub async fn loader_fields_list( - minecraft_meta: web::Data, + minecraft_meta: web::Data, query: web::Query, ) -> Result { let query = query.into_inner(); let mut results = match query.loader_field.as_str() { "game_versions" => { - let mut conn = minecraft_meta - .0 - .connection() - .await - .wrap_internal_err("acquiring minecraft-meta connection")?; - MinecraftVersion::all() - .exec(&mut conn) + let versions = minecraft_meta + .minecraft_versions() .await - .wrap_internal_err("fetching minecraft-meta game versions")? - .into_iter() + .wrap_internal_err("fetching minecraft-meta game versions")?; + versions + .iter() .map(|row| { let name = row.name.to_string(); - let entry = row.catalog_entry.0; + let entry = &row.catalog_entry.0; let ty = match entry.ty { VersionType::Release => "release", VersionType::Snapshot => "snapshot", diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index c4723f6993..4dcd753b7c 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -26,6 +26,7 @@ use crate::models::users::User; use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::search::SearchState; +use crate::util::MinecraftMeta; use crate::util::kafka::KafkaClientState; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -34,6 +35,7 @@ use actix_multipart::{Field, Multipart}; use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, post, web}; use chrono::Utc; +use eyre::WrapErr as _; use futures::stream::StreamExt; use hex::ToHex; use itertools::Itertools; @@ -128,6 +130,7 @@ struct InitialFileData { )] #[post("/version")] pub async fn version_create_route( + minecraft_meta: web::Data, req: HttpRequest, payload: Multipart, client: Data, @@ -138,6 +141,7 @@ pub async fn version_create_route( search_state: Data, ) -> Result { version_create( + minecraft_meta.clone(), req, payload, client, @@ -151,6 +155,7 @@ pub async fn version_create_route( } pub async fn version_create( + minecraft_meta: web::Data, req: HttpRequest, mut payload: Multipart, client: Data, @@ -176,6 +181,7 @@ pub async fn version_create( let result = async { let (response, project_id, version_id) = version_create_inner( + minecraft_meta.clone(), &mut payload, &mut transaction, &redis, @@ -228,6 +234,7 @@ pub async fn version_create( #[allow(clippy::too_many_arguments)] async fn version_create_inner( + minecraft_meta: web::Data, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, redis: &RedisPool, @@ -375,6 +382,7 @@ async fn version_create_inner( let loader_ids: Vec = loaders.iter().map(|y| y.id).collect_vec(); let component = create_minecraft_component( + minecraft_meta.clone(), &version_create_data.fields, version_create_data.minecraft_mod.as_ref(), &loaders, @@ -436,6 +444,7 @@ async fn version_create_inner( .map(|component| component.game_versions.clone()).unwrap_or_default(); upload_file( + minecraft_meta.clone(), &mut field, file_host, version_data.file_parts.len(), @@ -510,12 +519,15 @@ async fn version_create_inner( .await?; let loader_structs = selected_loaders.unwrap_or_default(); - let (all_project_types, all_games): (Vec, Vec) = - loader_structs.iter().fold((vec![], vec![]), |mut acc, x| { - acc.0.extend_from_slice(&x.supported_project_types); - acc.1.extend(x.supported_games.clone()); - acc - }); + let all_project_types = loader_structs + .iter() + .flat_map(|loader| loader.supported_project_types.clone()) + .collect(); + let all_games = if loader_structs.is_empty() { + Vec::new() + } else { + vec!["minecraft-java".to_owned()] + }; let response = Version { id: builder.version_id.into(), @@ -641,6 +653,7 @@ async fn version_create_inner( )] #[post("/version/{version_id}/file")] pub async fn upload_file_to_version_route( + minecraft_meta: web::Data, req: HttpRequest, url_data: web::Path<(VersionId,)>, payload: Multipart, @@ -652,6 +665,7 @@ pub async fn upload_file_to_version_route( search_state: Data, ) -> Result { upload_file_to_version( + minecraft_meta.clone(), req, url_data, payload, @@ -666,6 +680,7 @@ pub async fn upload_file_to_version_route( } pub async fn upload_file_to_version( + minecraft_meta: web::Data, req: HttpRequest, url_data: web::Path<(VersionId,)>, mut payload: Multipart, @@ -695,6 +710,7 @@ pub async fn upload_file_to_version( let result = async { let (response, project_id) = upload_file_to_version_inner( + minecraft_meta.clone(), &mut payload, client.clone(), &mut transaction, @@ -748,6 +764,7 @@ pub async fn upload_file_to_version( #[allow(clippy::too_many_arguments)] async fn upload_file_to_version_inner( + minecraft_meta: web::Data, payload: &mut Multipart, client: Data, transaction: &mut PgTransaction<'_>, @@ -899,6 +916,7 @@ async fn upload_file_to_version_inner( .unwrap_or_default(); upload_file( + minecraft_meta.clone(), &mut field, file_host, 0, @@ -962,6 +980,7 @@ async fn upload_file_to_version_inner( // files for a version, and for uploading the initial version files for a project #[allow(clippy::too_many_arguments)] pub async fn upload_file( + minecraft_meta: web::Data, field: &mut Field, file_host: &dyn FileHost, total_files_len: usize, @@ -1033,6 +1052,7 @@ pub async fn upload_file( let data = data.freeze(); let validation_result = validate_file( + minecraft_meta.clone(), data.clone(), file_extension.to_string(), loaders.clone(), @@ -1200,14 +1220,12 @@ pub fn get_name_and_extension( } pub async fn validate_minecraft_component( + minecraft_meta: web::Data, component: &exp::minecraft::ModVersion, loaders: &[models::loader_item::Loader], ) -> eyre::Result<()> { eyre::ensure!( - loaders.iter().any(|loader| loader - .supported_games - .iter() - .any(|game| game == "minecraft-java")), + !loaders.is_empty(), "Minecraft fields do not apply to the supplied loaders" ); eyre::ensure!( @@ -1219,13 +1237,14 @@ pub async fn validate_minecraft_component( || loaders.iter().any(|loader| loader.loader == "mrpack"), "`mrpack_loaders` requires the `mrpack` loader" ); - crate::models::minecraft::validate_minecraft_versions( - &component.game_versions, - ) - .await + minecraft_meta + .validate_minecraft_versions(&component.game_versions) + .await + .wrap_err("validating minecraft game versions") } pub async fn create_minecraft_component( + minecraft_meta: web::Data, fields: &HashMap, typed: Option<&exp::minecraft::ModVersion>, loaders: &[models::loader_item::Loader], @@ -1235,12 +1254,7 @@ pub async fn create_minecraft_component( typed.is_none() || fields.is_empty(), "cannot combine typed and flattened Minecraft fields" ); - let applies = loaders.iter().any(|loader| { - loader - .supported_games - .iter() - .any(|game| game == "minecraft-java") - }); + let applies = !loaders.is_empty(); if !applies && typed.is_none() && fields.is_empty() { return Ok(None); } @@ -1254,7 +1268,12 @@ pub async fn create_minecraft_component( None => crate::models::minecraft::parse_minecraft_fields(fields)? .create()?, }; - validate_minecraft_component(&component, loaders).await?; + validate_minecraft_component( + minecraft_meta.clone(), + &component, + loaders, + ) + .await?; eyre::Ok(Some(component)) } .await; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 791cd3b9b6..b1c906cc7d 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -1,3 +1,4 @@ +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use std::collections::HashMap; @@ -432,6 +433,7 @@ pub struct EditVersionFileType { )] #[patch("/version/{id}")] pub async fn version_edit_route( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(VersionId,)>, pool: web::Data, @@ -441,6 +443,7 @@ pub async fn version_edit_route( search_state: web::Data, ) -> Result { version_edit( + minecraft_meta.clone(), req, info, pool, @@ -453,6 +456,7 @@ pub async fn version_edit_route( } pub async fn version_edit( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(VersionId,)>, pool: web::Data, @@ -463,6 +467,7 @@ pub async fn version_edit( ) -> Result { let new_version = new_version.into_inner(); version_edit_helper( + minecraft_meta.clone(), req, info.into_inner(), pool, @@ -474,6 +479,7 @@ pub async fn version_edit( .await } pub async fn version_edit_helper( + minecraft_meta: web::Data, req: HttpRequest, info: (VersionId,), pool: web::Data, @@ -768,6 +774,7 @@ pub(crate) async fn version_edit_in_transaction( ))); } super::version_creation::validate_minecraft_component( + minecraft_meta.clone(), &component, &selected_loaders, ) @@ -1036,6 +1043,7 @@ pub struct VersionListFilters { )] #[get("/{project_id}/version")] pub async fn version_list( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query, @@ -1058,6 +1066,7 @@ pub async fn version_list( } version_list_internal( + minecraft_meta.clone(), req, info, web::Query(filters), @@ -1071,15 +1080,18 @@ pub async fn version_list( /// Load the shared loader/game-version pairs used by automatic featuring. pub(crate) async fn auto_featured_filters( + minecraft_meta: web::Data, ro_pool: &ReadOnlyPgPool, redis: &RedisPool, ) -> Result, ApiError> { - let (loaders, game_versions) = futures::future::try_join( - database::models::loader_item::Loader::list(&**ro_pool, redis), - crate::models::minecraft::minecraft_versions(), - ) - .await - .wrap_internal_err("fetching automatic featured filters")?; + let loaders = + database::models::loader_item::Loader::list(&**ro_pool, redis) + .await + .wrap_internal_err("fetching automatic featured filters")?; + let game_versions = minecraft_meta + .minecraft_versions() + .await + .wrap_internal_err("fetching minecraft versions for automatic featured filters")?; Ok(game_versions .iter() .filter(|version| { @@ -1096,6 +1108,7 @@ pub(crate) async fn auto_featured_filters( /// Apply project-list filters, including the legacy automatic featured fallback. pub(crate) async fn filter_project_versions( + minecraft_meta: web::Data, versions: Vec, filters: &VersionListFilters, ro_pool: &ReadOnlyPgPool, @@ -1192,7 +1205,9 @@ pub(crate) async fn filter_project_versions( let joined_filters = if let Some(filters) = auto_featured { filters } else { - loaded_filters = auto_featured_filters(ro_pool, redis).await?; + loaded_filters = + auto_featured_filters(minecraft_meta.clone(), ro_pool, redis) + .await?; &loaded_filters }; @@ -1226,6 +1241,7 @@ pub(crate) async fn filter_project_versions( } pub async fn version_list_internal( + minecraft_meta: web::Data, req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query, @@ -1266,9 +1282,15 @@ pub async fn version_list_internal( ) .await .wrap_internal_err("fetching versions from database")?; - let response = - filter_project_versions(versions, &filters, &ro_pool, &redis, None) - .await?; + let response = filter_project_versions( + minecraft_meta.clone(), + versions, + &filters, + &ro_pool, + &redis, + None, + ) + .await?; let mut response = filter_visible_versions(response, &user_option, &ro_pool, &redis) diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs index f62ca91211..5f314d636b 100644 --- a/apps/labrinth/src/test/mod.rs +++ b/apps/labrinth/src/test/mod.rs @@ -55,16 +55,19 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { .expect("Kafka connection failed"), ); - let minecraft_meta_db = web::Data::new( - crate::database::MinecraftMetaDb::connect() - .await - .expect("Minecraft metadata database connection failed"), - ); + let minecraft_meta_db = crate::database::MinecraftMetaDb::connect() + .await + .expect("Minecraft metadata database connection failed"); + let minecraft_meta = web::Data::new( + crate::util::MinecraftMeta::new(minecraft_meta_db) + .await + .expect("Minecraft metadata initialization failed"), + ); crate::app_setup( pool.clone(), ro_pool.clone(), - minecraft_meta_db, + minecraft_meta, redis_pool.clone(), search_backend.into(), &mut clickhouse, diff --git a/apps/labrinth/src/util/minecraft_meta.rs b/apps/labrinth/src/util/minecraft_meta.rs new file mode 100644 index 0000000000..c5898e540c --- /dev/null +++ b/apps/labrinth/src/util/minecraft_meta.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use eyre::{Result, WrapErr}; +use minecraft_meta_model::model::{MinecraftVersion, MinecraftVersionName}; +use tokio::sync::{Mutex, RwLock}; + +use crate::database::MinecraftMetaDb; +use crate::models::minecraft::{legacy_game_version_name, minecraft_version_date}; + +const CACHE_EXPIRY: Duration = Duration::from_secs(30 * 60); + + +/// Shared Minecraft metadata refreshed on demand after thirty minutes. +pub struct MinecraftMeta { + db: Mutex, + cache: RwLock>, + clock_origin: Instant, + /// Expiration in elapsed seconds from the monotonic clock origin. + expires_at: AtomicU64, +} + +impl MinecraftMeta { + pub async fn new(db: MinecraftMetaDb) -> Result { + let versions = Self::load_versions(&db.0).await?; + Ok(Self { + db: Mutex::new(db.0), + cache: RwLock::new(versions), + clock_origin: Instant::now(), + expires_at: AtomicU64::new(CACHE_EXPIRY.as_secs()), + }) + } + + pub async fn minecraft_versions(&self) -> Result> { + if !self.is_expired() { + return Ok(Arc::clone(&*self.cache.read().await)); + } + + let db = self.db.lock().await; + if !self.is_expired() { + return Ok(Arc::clone(&*self.cache.read().await)); + } + + let versions = Self::load_versions(&db).await?; + let mut cache = self.cache.write().await; + *cache = Arc::clone(&versions); + self.expires_at.store( + self.clock_origin.elapsed().as_secs().saturating_add(CACHE_EXPIRY.as_secs()), + Ordering::Release, + ); + Ok(versions) + } + + fn is_expired(&self) -> bool { + self.clock_origin.elapsed().as_secs() >= self.expires_at.load(Ordering::Acquire) + } + + pub async fn validate_minecraft_versions( + &self, + names: &[MinecraftVersionName], + ) -> Result<()> { + if names.is_empty() { + return Ok(()); + } + let versions = self.minecraft_versions().await?; + eyre::ensure!( + versions.iter().any(|version| { + names.iter().any(|name| { + legacy_game_version_name(&name.0) == version.name.0 + }) + }), + "no supplied Minecraft version exists in minecraft-meta" + ); + Ok(()) + } + + async fn load_versions(db: &toasty::Db) -> Result> { + let mut connection = db.connection().await + .wrap_err("acquiring minecraft-meta connection")?; + let mut versions = MinecraftVersion::all() + .exec(&mut connection) + .await + .wrap_err("loading Minecraft game versions")?; + + for version in &mut versions { + version.name = MinecraftVersionName( + legacy_game_version_name(&version.name.0).to_owned(), + ); + } + versions.sort_by(|a, b| { + minecraft_version_date(b) + .cmp(&minecraft_version_date(a)) + .then_with(|| a.name.0.cmp(&b.name.0)) + }); + Ok(versions.into()) + } +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index efe8c1dbc9..b20f7c083d 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -15,6 +15,7 @@ pub mod http; pub mod img; pub mod ip; pub mod kafka; +pub mod minecraft_meta; pub mod neverbounce; pub mod non_empty_vec; pub mod ratelimit; @@ -25,3 +26,5 @@ pub mod tiltify; pub mod usercheck; pub mod validate; pub mod webhook; + +pub use minecraft_meta::MinecraftMeta; diff --git a/apps/labrinth/src/util/tags.rs b/apps/labrinth/src/util/tags.rs index d0e9f04491..82615c5734 100644 --- a/apps/labrinth/src/util/tags.rs +++ b/apps/labrinth/src/util/tags.rs @@ -1,6 +1,6 @@ use crate::database::PgPool; use crate::database::models::loader_item::Loader; -use crate::models::minecraft::minecraft_versions; +use crate::util::MinecraftMeta; use crate::routes::ApiError; use crate::util::error::Context; use arc_swap::ArcSwapOption; @@ -20,13 +20,14 @@ pub struct DownloadTagsCache { pub game_versions: HashSet, } -/// Fetches download tags from the database or returns a cached version. +/// Fetches download tags from loader taxonomy and cached Minecraft metadata. /// /// We cache tags since we get a large volume of download ingests, and querying /// the database or even Redis for each request is too expensive. pub async fn valid_download_tags( pool: &PgPool, redis: &RedisPool, + minecraft_meta: &MinecraftMeta, ) -> Result, ApiError> { const DOWNLOAD_TAGS_CACHE_TTL: Duration = Duration::from_secs(60 * 5); @@ -58,11 +59,13 @@ pub async fn valid_download_tags( .into_iter() .map(|loader| loader.loader) .collect(); - let game_versions = minecraft_versions() + let versions = minecraft_meta + .minecraft_versions() .await - .wrap_internal_err("failed to fetch game versions")? - .into_iter() - .map(|game_version| game_version.name.0) + .wrap_internal_err("fetching minecraft version catalog")?; + let game_versions = versions + .iter() + .map(|game_version| game_version.name.0.clone()) .collect(); let cache = Arc::new(DownloadTagsCache { diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index d08c04c875..49c90186ac 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -1,6 +1,7 @@ use crate::models::ids::ProjectId; -use crate::models::minecraft::{minecraft_version_date, minecraft_versions}; +use crate::models::minecraft::minecraft_version_date; use crate::routes::ApiError; +use crate::util::MinecraftMeta; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use crate::{database::PgPool, env::ENV}; @@ -46,6 +47,7 @@ struct WebhookAuthor { } async fn get_webhook_metadata( + minecraft_meta: &MinecraftMeta, project_id: ProjectId, pool: &PgPool, redis: &RedisPool, @@ -111,9 +113,10 @@ async fn get_webhook_metadata( } }; - let all_game_versions = minecraft_versions() + let all_game_versions = minecraft_meta + .minecraft_versions() .await - .wrap_internal_err("fetching minecraft version catalog")?; + .wrap_internal_err("fetching minecraft versions for webhook metadata")?; let supported_names = project .minecraft_mod_versions @@ -129,7 +132,8 @@ async fn get_webhook_metadata( .cloned() .collect(); - let formatted_game_versions = get_gv_range(versions, all_game_versions); + let formatted_game_versions = + get_gv_range(versions, all_game_versions.to_vec()); let mut project_type = project.project_types.pop().unwrap_or_default(); // TODO: Should this grab a not-first? @@ -191,15 +195,17 @@ async fn get_webhook_metadata( } pub async fn send_slack_project_webhook( + minecraft_meta: &MinecraftMeta, project_id: ProjectId, pool: &PgPool, redis: &RedisPool, webhook_url: &str, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis) - .await - .wrap_api_err("fetching webhook metadata")?; + let metadata = + get_webhook_metadata(minecraft_meta, project_id, pool, redis) + .await + .wrap_api_err("fetching webhook metadata")?; if let Some(metadata) = metadata { let mut blocks = vec![]; @@ -363,15 +369,17 @@ struct DiscordWebhook { } pub async fn send_discord_webhook( + minecraft_meta: &MinecraftMeta, project_id: ProjectId, pool: &PgPool, redis: &RedisPool, webhook_url: &str, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis) - .await - .wrap_api_err("fetching webhook metadata")?; + let metadata = + get_webhook_metadata(minecraft_meta, project_id, pool, redis) + .await + .wrap_api_err("fetching webhook metadata")?; if let Some(project) = metadata { let mut fields = vec![]; diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index 986c922fe4..c0cde35e78 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -1,10 +1,12 @@ +use actix_web::web; use crate::database::PgTransaction; use crate::database::models::loader_item::Loader as DBLoader; use crate::database::models::version_item::DependencyBuilder; use crate::models::ids::ProjectId; -use crate::models::minecraft::{minecraft_version_date, minecraft_versions}; +use crate::models::minecraft::minecraft_version_date; use crate::models::pack::PackFormat; use crate::models::projects::{DependencyType, FileType, Loader}; +use crate::util::MinecraftMeta; use crate::validate::datapack::DataPackValidator; use crate::validate::fabric::FabricValidator; use crate::validate::forge::{ForgeValidator, LegacyForgeValidator}; @@ -21,10 +23,11 @@ use crate::validate::shader::{ }; use bytes::Bytes; use chrono::{DateTime, Utc}; +use eyre::WrapErr as _; use minecraft_meta_model::model::{MinecraftVersion, MinecraftVersionName}; use std::io::{self, Cursor}; use std::mem; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; use thiserror::Error; use xredis::RedisPool; use zip::ZipArchive; @@ -191,6 +194,7 @@ static PLAUSIBLE_PACK_REGEX: LazyLock = /// The return value is whether this file should be marked as primary or not, based on the analysis of the file #[allow(clippy::too_many_arguments)] pub async fn validate_file( + minecraft_meta: web::Data, data: bytes::Bytes, file_extension: String, loaders: Vec, @@ -200,7 +204,10 @@ pub async fn validate_file( transaction: &mut PgTransaction<'_>, redis: &RedisPool, ) -> Result { - let all_game_versions = minecraft_versions().await?; + let all_game_versions = minecraft_meta + .minecraft_versions() + .await + .wrap_err("fetching minecraft versions for file validation")?; let game_versions = game_versions.iter().map(|name| { let name = crate::models::minecraft::legacy_game_version_name(&name.0); all_game_versions.iter().find(|version| version.name.0 == name) @@ -221,11 +228,11 @@ pub async fn validate_file( )?; validate_minecraft_file( + all_game_versions, data, file_extension, loaders, game_versions, - all_game_versions, file_type, ) .await @@ -313,11 +320,11 @@ fn validate_additional_jar( } async fn validate_minecraft_file( + all_game_versions: Arc<[MinecraftVersion]>, data: Bytes, file_extension: String, loaders: Vec, game_versions: Vec, - all_game_versions: Vec, file_type: Option, ) -> Result { actix_web::web::block(move || { From 1cf222cd3791023349495c53bda1a53a0e824165 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:21:34 +0100 Subject: [PATCH 08/13] update dummy fixtures --- apps/labrinth/fixtures/dummy_data.sql | 49 -------------- .../fixtures/minecraft-meta-dummy-data.sql | 49 ++++++++++++++ apps/labrinth/src/database/mod.rs | 20 +++--- apps/labrinth/src/test/database.rs | 9 +++ apps/labrinth/src/test/db.rs | 13 ++++ apps/labrinth/src/test/dummy_data.rs | 2 +- apps/labrinth/src/test/mod.rs | 8 ++- apps/labrinth/tests/loader_fields.rs | 66 ++++++++----------- apps/labrinth/tests/v2/tags.rs | 10 +-- 9 files changed, 116 insertions(+), 110 deletions(-) create mode 100644 apps/labrinth/fixtures/minecraft-meta-dummy-data.sql diff --git a/apps/labrinth/fixtures/dummy_data.sql b/apps/labrinth/fixtures/dummy_data.sql index 17b27a8abf..0450fc5cea 100644 --- a/apps/labrinth/fixtures/dummy_data.sql +++ b/apps/labrinth/fixtures/dummy_data.sql @@ -44,11 +44,6 @@ SELECT lpt.joining_loader_id, lpt.joining_project_type_id, 1 FROM loaders_project_types AS lpt WHERE lpt.joining_loader_id IN (7, 8, 9); --- Adds dummies to mrpack_loaders -INSERT INTO loader_field_enum_values (enum_id, value) -SELECT id, 'fabric' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; -INSERT INTO loader_field_enum_values (enum_id, value) -SELECT id, 'forge' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 @@ -59,50 +54,6 @@ SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 6; --- Dummy-data only optional field, as we don't have any yet -INSERT INTO loader_fields - (field, field_type, optional) -VALUES - ('test_fabric_optional', 'integer', TRUE); -INSERT INTO loader_fields_loaders (loader_id, loader_field_id) -SELECT l.id, lf.id -FROM - loaders AS l - CROSS JOIN loader_fields AS lf -WHERE lf.field = 'test_fabric_optional' AND l.loader = 'fabric' -ON CONFLICT DO NOTHING; - --- Sample game versions, loaders, categories --- Game versions is '2' -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES - (2, '1.3', '{"type":"snapshot","major":false}', '2012-07-25 22:00:00+00'), - (2, '1.13.2', '{"type":"release","major":false}', '2018-10-22 11:41:07+00'), - (2, '18w43b', '{"type":"snapshot","major":false}', '2018-10-24 15:02:30+00'); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES (2, '1.20.1', '{"type":"release","major":false}', '2021-08-18 15:48:58.435729+00'); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES (2, '1.20.2', '{"type":"release","major":false}', '2021-08-18 15:48:59.435729+00'); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES (2, '1.20.3', '{"type":"release","major":false}', '2021-08-18 15:49:00.435729+00'); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES (2, '1.20.4', '{"type":"beta","major":false}', '2021-08-18 15:49:01.435729+00'); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, created) -VALUES (2, '1.20.5', '{"type":"release","major":true}', '2061-08-18 15:49:02.435729+00'); - --- Also add 'Ordering_Negative1' and 'Ordering_Positive100' to game versions (to test ordering override) -INSERT INTO loader_field_enum_values (enum_id, value, metadata, ordering) -VALUES (2, 'Ordering_Negative1', '{"type":"release","major":false}', -1); -INSERT INTO loader_field_enum_values (enum_id, value, metadata, ordering) -VALUES (2, 'Ordering_Positive100', '{"type":"release","major":false}', 100); - -INSERT INTO loader_fields_loaders (loader_id, loader_field_id) -SELECT l.id, lf.id -FROM - loaders AS l - CROSS JOIN loader_fields AS lf -WHERE lf.field IN ('game_versions', 'environment') -ON CONFLICT DO NOTHING; INSERT INTO categories (id, category, project_type) VALUES diff --git a/apps/labrinth/fixtures/minecraft-meta-dummy-data.sql b/apps/labrinth/fixtures/minecraft-meta-dummy-data.sql new file mode 100644 index 0000000000..2a52131ce9 --- /dev/null +++ b/apps/labrinth/fixtures/minecraft-meta-dummy-data.sql @@ -0,0 +1,49 @@ +-- Metadata-only catalog for isolated Labrinth test databases, not playable manifests. +CREATE TABLE IF NOT EXISTS minecraft_versions ( + name TEXT PRIMARY KEY, + catalog_entry JSON NOT NULL, + manifest JSON NOT NULL, + source_catalog_sha256 TEXT NOT NULL, + source_manifest_sha256 TEXT NOT NULL, + is_latest_release BOOLEAN, + is_latest_snapshot BOOLEAN +); + +CREATE UNIQUE INDEX IF NOT EXISTS index_minecraft_versions_by_is_latest_release + ON minecraft_versions (is_latest_release); +CREATE UNIQUE INDEX IF NOT EXISTS index_minecraft_versions_by_is_latest_snapshot + ON minecraft_versions (is_latest_snapshot); + +INSERT INTO minecraft_versions ( + name, catalog_entry, manifest, source_catalog_sha256, source_manifest_sha256, + is_latest_release, is_latest_snapshot +) +SELECT + name, + json_build_object( + 'id', name, 'type', version_type, + 'url', 'https://metadata-only.invalid/manifest', + 'time', released_at, 'releaseTime', released_at, + 'sha1', repeat('0', 40), 'complianceLevel', 0 + ), + json_build_object( + 'id', name, 'type', version_type, 'time', released_at, 'releaseTime', released_at, + 'assetIndex', json_build_object( + 'id', '', 'sha1', repeat('0', 40), 'size', 0, 'totalSize', 0, + 'url', 'https://metadata-only.invalid/assets' + ), + 'assets', '', 'downloads', '{}'::json, 'libraries', '[]'::json, + 'mainClass', '', 'minimumLauncherVersion', 0 + ), + repeat('0', 64), repeat('0', 64), NULL::boolean, NULL::boolean +FROM (VALUES + ('1.3', 'snapshot', '2012-07-25T22:00:00Z'), + ('1.13.2', 'release', '2018-10-22T11:41:07Z'), + ('18w43b', 'snapshot', '2018-10-24T15:02:30Z'), + ('1.20.1', 'release', '2021-08-18T15:48:58.435729Z'), + ('1.20.2', 'release', '2021-08-18T15:48:59.435729Z'), + ('1.20.3', 'release', '2021-08-18T15:49:00.435729Z'), + ('1.20.4', 'old_beta', '2021-08-18T15:49:01.435729Z'), + ('1.20.5', 'release', '2061-08-18T15:49:02.435729Z') +) AS versions(name, version_type, released_at) +ON CONFLICT (name) DO NOTHING; diff --git a/apps/labrinth/src/database/mod.rs b/apps/labrinth/src/database/mod.rs index c7db01b16d..ddc28fae47 100644 --- a/apps/labrinth/src/database/mod.rs +++ b/apps/labrinth/src/database/mod.rs @@ -3,16 +3,18 @@ use eyre::{Result, WrapErr}; pub struct MinecraftMetaDb(pub toasty::Db); impl MinecraftMetaDb { - pub async fn connect() -> Result { - let database_url = &crate::env::ENV.MINECRAFT_META_DATABASE_URL; + pub async fn connect() -> Result { + Self::connect_url(&crate::env::ENV.MINECRAFT_META_DATABASE_URL).await + } - let db = toasty::Db::builder() - .models(toasty::models!(minecraft_meta_model::*)) - .connect(database_url.as_str()) - .await - .wrap_err("connecting to Minecraft metadata database")?; - Ok(Self(db)) - } + pub async fn connect_url(database_url: &url::Url) -> Result { + let db = toasty::Db::builder() + .models(toasty::models!(minecraft_meta_model::*)) + .connect(database_url.as_str()) + .await + .wrap_err("connecting to Minecraft metadata database")?; + Ok(Self(db)) + } } pub mod advisory_lock; diff --git a/apps/labrinth/src/test/database.rs b/apps/labrinth/src/test/database.rs index deb7187ffe..ed3e2014c0 100644 --- a/apps/labrinth/src/test/database.rs +++ b/apps/labrinth/src/test/database.rs @@ -44,6 +44,7 @@ pub struct TemporaryDatabase { pub ro_pool: ReadOnlyPgPool, pub redis_pool: RedisPool, pub database_name: String, + pub database_url: Url, pub search_backend: Arc, } @@ -86,6 +87,9 @@ impl TemporaryDatabase { // Performs migrations MIGRATOR.run(&*pool).await.expect("Migrations failed"); + super::db::add_minecraft_meta_dummy_data(&pool) + .await + .expect("Minecraft metadata fixture initialization failed"); println!("Migrations complete"); @@ -99,6 +103,7 @@ impl TemporaryDatabase { pool, ro_pool, database_name: temp_database_name, + database_url: temporary_url, redis_pool, search_backend: Arc::from(search_backend), } @@ -187,12 +192,16 @@ impl TemporaryDatabase { MIGRATOR.run(&*pool).await.expect("Migrations failed"); if !dummy_data_exists { + super::db::add_minecraft_meta_dummy_data(&pool) + .await + .expect("Minecraft metadata fixture initialization failed"); // Add dummy data let name = generate_random_name("test_template_"); let db = TemporaryDatabase { pool: pool.clone(), ro_pool: ReadOnlyPgPool::from(pool.clone()), database_name: TEMPLATE_DATABASE_NAME.to_string(), + database_url: template_url, redis_pool: crate::database::redis::from_env( name.clone(), ) diff --git a/apps/labrinth/src/test/db.rs b/apps/labrinth/src/test/db.rs index 500cb2700e..d9c0f0ef48 100644 --- a/apps/labrinth/src/test/db.rs +++ b/apps/labrinth/src/test/db.rs @@ -65,6 +65,19 @@ pub mod user_id { pub const ENEMY: DBUserId = DBUserId(5); } +/// Initialize the Minecraft metadata catalog in an isolated test database. +/// +/// # Errors +/// +/// Errors if the fixture could not be applied. +pub async fn add_minecraft_meta_dummy_data(db: &PgPool) -> Result<()> { + db.execute(include_str!("../../fixtures/minecraft-meta-dummy-data.sql")) + .await + .wrap_err("failed to add Minecraft metadata dummy data")?; + + Ok(()) +} + /// Initialize a database with dummy fixture data. /// /// # Errors diff --git a/apps/labrinth/src/test/dummy_data.rs b/apps/labrinth/src/test/dummy_data.rs index 01ab2a23ff..bd49c1eac2 100644 --- a/apps/labrinth/src/test/dummy_data.rs +++ b/apps/labrinth/src/test/dummy_data.rs @@ -25,7 +25,7 @@ use super::{ use super::{database::USER_USER_ID, get_json_val_str}; -pub const DUMMY_DATA_UPDATE: i64 = 11; +pub const DUMMY_DATA_UPDATE: i64 = 12; pub const DUMMY_CATEGORIES: &[&str] = &[ "combat", diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs index 5f314d636b..8ddeed1aa1 100644 --- a/apps/labrinth/src/test/mod.rs +++ b/apps/labrinth/src/test/mod.rs @@ -55,9 +55,11 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { .expect("Kafka connection failed"), ); - let minecraft_meta_db = crate::database::MinecraftMetaDb::connect() - .await - .expect("Minecraft metadata database connection failed"); + + let minecraft_meta_db = + crate::database::MinecraftMetaDb::connect_url(&db.database_url) + .await + .expect("Minecraft metadata database connection failed"); let minecraft_meta = web::Data::new( crate::util::MinecraftMeta::new(minecraft_meta_db) .await diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index f772f2189c..1bf50ab3f9 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -109,7 +109,7 @@ async fn creating_loader_fields() { .await; assert_status!(&resp, StatusCode::BAD_REQUEST); - // Cannot create a version without an applicable loader field that is not optional + // Environment is optional for Minecraft versions // TODO: - Create project // - Create version let resp = api @@ -129,7 +129,7 @@ async fn creating_loader_fields() { ) .await; - assert_status!(&resp, StatusCode::BAD_REQUEST); + assert_status!(&resp, StatusCode::OK); // Cannot create a version without a loader field array that has a minimum of 1 // TODO: - Create project @@ -228,11 +228,11 @@ async fn creating_loader_fields() { assert_status!(&resp, StatusCode::BAD_REQUEST); } - // Can create with optional loader fields (other tests have checked if we can create without them) + // Removed arbitrary loader fields are rejected on create and patch // TODO: - Create project // - Create version - let v = api - .add_public_version_deserialized( + let resp = api + .add_public_version( *alpha_project_id_parsed, "1.0.0", TestFile::build_random_jar(), @@ -248,7 +248,7 @@ async fn creating_loader_fields() { USER_USER_PAT, ) .await; - assert_eq!(&v.fields["test_fabric_optional"], &json!(555)); + assert_status!(&resp, StatusCode::BAD_REQUEST); // - Patch let resp = api .edit_version( @@ -259,11 +259,11 @@ async fn creating_loader_fields() { USER_USER_PAT, ) .await; - assert_status!(&resp, StatusCode::NO_CONTENT); + assert_status!(&resp, StatusCode::BAD_REQUEST); let v = api .get_version_deserialized(alpha_version_id, USER_USER_PAT) .await; - assert_eq!(&v.fields["test_fabric_optional"], &json!(555)); + assert!(!v.fields.contains_key("test_fabric_optional")); // Simply setting them as expected works // - Create @@ -395,10 +395,10 @@ async fn version_edit_uses_submitted_loaders_for_field_validation() { .edit_version( version_id, json!({ - "loaders": ["fabric"], + "loaders": ["mrpack"], "game_versions": ["1.20.1"], "environment": "client_only", - "test_fabric_optional": 42 + "mrpack_loaders": ["fabric"] }), USER_USER_PAT, ) @@ -409,10 +409,10 @@ async fn version_edit_uses_submitted_loaders_for_field_validation() { .get_version_deserialized(version_id, USER_USER_PAT) .await; assert_eq!(version.loaders.len(), 1); - assert_eq!(version.loaders[0].0, "fabric"); + assert_eq!(version.loaders[0].0, "mrpack"); assert_eq!(version.fields["game_versions"], json!(["1.20.1"])); assert_eq!(version.fields["environment"], json!("client_only")); - assert_eq!(version.fields["test_fabric_optional"], json!(42)); + assert_eq!(version.fields["mrpack_loaders"], json!(["fabric"])); }, ) .await; @@ -438,7 +438,7 @@ async fn version_edit_uses_authoritative_loaders_for_field_validation() { String, > = redis.get_deserialized(&version_key).await.unwrap().unwrap(); let mut stale_version = cached_version.value().clone(); - stale_version.loaders = vec!["bukkit".to_string()]; + stale_version.loaders = vec!["mrpack".to_string()]; redis .set_serialized( &version_key, @@ -458,24 +458,25 @@ async fn version_edit_uses_authoritative_loaders_for_field_validation() { .edit_version( version_id, json!({ - "test_fabric_optional": 73 + "mrpack_loaders": ["fabric"] }), USER_USER_PAT, ) .await; - assert_status!(&response, StatusCode::NO_CONTENT); + assert_status!(&response, StatusCode::BAD_REQUEST); - let version = api - .get_version_deserialized(version_id, USER_USER_PAT) - .await; - assert_eq!(version.fields["test_fabric_optional"], json!(73)); + let body: serde_json::Value = test::read_body_json(response).await; + assert_eq!( + body["description"], + "`mrpack_loaders` requires the `mrpack` loader" + ); }, ) .await; } #[actix_rt::test] -async fn version_edit_lists_all_incompatible_loader_fields() { +async fn version_edit_rejects_incompatible_loader_fields() { with_test_environment(None, |test_env: TestEnvironment| async move { let api = &test_env.api; let version_id = &test_env.dummy.project_alpha.version_id; @@ -495,7 +496,6 @@ async fn version_edit_lists_all_incompatible_loader_fields() { .edit_version( version_id, json!({ - "test_fabric_optional": 42, "mrpack_loaders": ["fabric"], "environment": "client_only" }), @@ -507,7 +507,7 @@ async fn version_edit_lists_all_incompatible_loader_fields() { assert_eq!(body["error"], "request_error"); assert_eq!( body["description"], - "loader fields `mrpack_loaders`, `test_fabric_optional` do not exist for any loaders supplied" + "`mrpack_loaders` requires the `mrpack` loader" ); }) .await; @@ -522,13 +522,7 @@ async fn get_loader_fields_variants() { .get_loader_field_variants_deserialized("game_versions") .await; - // These tests match dummy data and will need to be updated if the dummy data changes - // Versions should be ordered by: - // - ordering - // - ordering ties settled by date added to database - // - We also expect presentation of NEWEST to OLDEST - // - All null orderings are treated as older than any non-null ordering - // (for this test, the 1.20.1, etc, versions are all null ordering) + // Minecraft versions are presented newest to oldest by release date. let game_version_versions = game_versions .into_iter() .map(|x| x.value) @@ -536,8 +530,6 @@ async fn get_loader_fields_variants() { assert_eq!( game_version_versions, [ - "Ordering_Negative1", - "Ordering_Positive100", "1.20.5", "1.20.4", "1.20.3", @@ -572,14 +564,10 @@ async fn get_available_loader_fields() { .collect::>(); assert_eq!( fabric_loader_fields, - [ - "game_versions", - "environment", - "test_fabric_optional" // exists for testing - ] - .iter() - .map(|s| s.to_string()) - .collect() + ["game_versions", "environment"] + .iter() + .map(|s| s.to_string()) + .collect() ); let mrpack_loader_fields = loaders diff --git a/apps/labrinth/tests/v2/tags.rs b/apps/labrinth/tests/v2/tags.rs index ee92894de4..acc9331fbe 100644 --- a/apps/labrinth/tests/v2/tags.rs +++ b/apps/labrinth/tests/v2/tags.rs @@ -18,13 +18,7 @@ async fn get_tags() { let loaders = api.get_loaders_deserialized().await; let side_types = api.get_side_types_deserialized().await; - // These tests match dummy data and will need to be updated if the dummy data changes - // Versions should be ordered by: - // - ordering - // - ordering ties settled by date added to database - // - We also expect presentation of NEWEST to OLDEST - // - All null orderings are treated as older than any non-null ordering - // (for this test, the 1.20.1, etc, versions are all null ordering) + // Minecraft versions are presented newest to oldest by release date. let game_version_versions = game_versions .into_iter() .map(|x| x.version) @@ -32,8 +26,6 @@ async fn get_tags() { assert_eq!( game_version_versions, [ - "Ordering_Negative1", - "Ordering_Positive100", "1.20.5", "1.20.4", "1.20.3", From 4cd3a4a0ba549562e6c7a2bb660860bd644d432c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:21:42 +0100 Subject: [PATCH 09/13] remove unused games tbls --- ...7a5f259032c549ebe5d1e6200f0093d9166ca.json | 15 -- ...0a4c9e6d84933464c0e610694743da4783354.json | 40 ----- ...347335aefa82740df1d6a917a1d45adc685fb.json | 34 ++++ ...96e871f1a2303df9bdd4ab1b38171a8596065.json | 34 ---- ...60466d989b5cd4a73926abacc3989b9e887ab.json | 36 ---- ...87be53ee30f967cd6726606f20a71e234106.json} | 10 +- ...4dd66540f9d51b167339b41a2f3858843f287.json | 40 ----- ...90628cd8663d14ce8c21466728c556d1bac4a.json | 34 ++++ ...c2028b5bcf23aaa68a351ea858d0d56681b4d.json | 15 -- ...9635bddfe43342ee549fad2433a271f8feeee.json | 44 ----- ...64b885b55320c2213ca2b5c56544204745495.json | 22 --- apps/labrinth/fixtures/dummy_data.sql | 15 -- .../labrinth-seed-data-202508052143.sql | 41 ----- .../20261013133423_delete_loader_fields.sql | 2 + apps/labrinth/src/background_task.rs | 165 ------------------ .../labrinth/src/database/models/game_item.rs | 82 --------- apps/labrinth/src/database/models/ids.rs | 2 +- .../src/database/models/loader_item.rs | 14 +- apps/labrinth/src/database/models/mod.rs | 2 +- .../src/database/models/project_item.rs | 20 +-- .../src/database/models/version_item.rs | 20 +-- apps/labrinth/src/database/redis/mod.rs | 4 - apps/labrinth/src/models/v3/projects.rs | 25 ++- apps/labrinth/src/routes/v3/mod.rs | 1 - apps/labrinth/src/routes/v3/tags.rs | 61 +------ apps/labrinth/src/routes/v3/versions.rs | 6 +- apps/labrinth/src/test/api_v3/tags.rs | 15 +- apps/labrinth/src/test/dummy_data.rs | 2 +- apps/labrinth/tests/games.rs | 29 --- 29 files changed, 131 insertions(+), 699 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json delete mode 100644 apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json create mode 100644 apps/labrinth/.sqlx/query-7331438df9133f505d27c9f772a347335aefa82740df1d6a917a1d45adc685fb.json delete mode 100644 apps/labrinth/.sqlx/query-7f483288e82d8c40adaed79463396e871f1a2303df9bdd4ab1b38171a8596065.json delete mode 100644 apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json rename apps/labrinth/.sqlx/{query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json => query-87c0b050d77309d568a361ae1cd987be53ee30f967cd6726606f20a71e234106.json} (53%) delete mode 100644 apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json create mode 100644 apps/labrinth/.sqlx/query-c60b772698eaf55b50c83a1266590628cd8663d14ce8c21466728c556d1bac4a.json delete mode 100644 apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json delete mode 100644 apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json delete mode 100644 apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json delete mode 100644 apps/labrinth/src/database/models/game_item.rs delete mode 100644 apps/labrinth/tests/games.rs diff --git a/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json b/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json deleted file mode 100644 index 1ff37817dd..0000000000 --- a/apps/labrinth/.sqlx/query-187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE versions SET components = jsonb_set(\n COALESCE(components, '{}'::jsonb), '{minecraft_mod}',\n jsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb)\n || COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb)\n || $2::jsonb\n ) WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "187c9b5bd36c3fa9003b0fc1dd07a5f259032c549ebe5d1e6200f0093d9166ca" -} diff --git a/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json b/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json deleted file mode 100644 index 826115438d..0000000000 --- a/apps/labrinth/.sqlx/query-5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "version_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 3, - "name": "games", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - null, - null, - null - ] - }, - "hash": "5905505b15c9eabb45cdf5acd740a4c9e6d84933464c0e610694743da4783354" -} diff --git a/apps/labrinth/.sqlx/query-7331438df9133f505d27c9f772a347335aefa82740df1d6a917a1d45adc685fb.json b/apps/labrinth/.sqlx/query-7331438df9133f505d27c9f772a347335aefa82740df1d6a917a1d45adc685fb.json new file mode 100644 index 0000000000..3bec6af50e --- /dev/null +++ b/apps/labrinth/.sqlx/query-7331438df9133f505d27c9f772a347335aefa82740df1d6a917a1d45adc685fb.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n\n WHERE v.id = ANY($1)\n GROUP BY mod_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "7331438df9133f505d27c9f772a347335aefa82740df1d6a917a1d45adc685fb" +} diff --git a/apps/labrinth/.sqlx/query-7f483288e82d8c40adaed79463396e871f1a2303df9bdd4ab1b38171a8596065.json b/apps/labrinth/.sqlx/query-7f483288e82d8c40adaed79463396e871f1a2303df9bdd4ab1b38171a8596065.json deleted file mode 100644 index 6e2ad1f740..0000000000 --- a/apps/labrinth/.sqlx/query-7f483288e82d8c40adaed79463396e871f1a2303df9bdd4ab1b38171a8596065.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id, v.mod_id, v.date_published\n FROM versions v\n WHERE mod_id = ANY($1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "date_published", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "7f483288e82d8c40adaed79463396e871f1a2303df9bdd4ab1b38171a8596065" -} diff --git a/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json b/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json deleted file mode 100644 index b02c6c7476..0000000000 --- a/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT mod_id, v.id as id, date_published\n FROM mods m\n INNER JOIN versions v ON m.id = v.mod_id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "date_published", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TextArray" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab" -} diff --git a/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json b/apps/labrinth/.sqlx/query-87c0b050d77309d568a361ae1cd987be53ee30f967cd6726606f20a71e234106.json similarity index 53% rename from apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json rename to apps/labrinth/.sqlx/query-87c0b050d77309d568a361ae1cd987be53ee30f967cd6726606f20a71e234106.json index 9fda28ebba..dc4011bd07 100644 --- a/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json +++ b/apps/labrinth/.sqlx/query-87c0b050d77309d568a361ae1cd987be53ee30f967cd6726606f20a71e234106.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n (l.metadata->>'platform')::boolean AS platform,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ", + "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n (l.metadata->>'platform')::boolean AS platform,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n\n GROUP BY l.id;\n ", "describe": { "columns": [ { @@ -27,11 +27,6 @@ "ordinal": 4, "name": "project_types", "type_info": "VarcharArray" - }, - { - "ordinal": 5, - "name": "games", - "type_info": "VarcharArray" } ], "parameters": { @@ -42,9 +37,8 @@ false, false, null, - null, null ] }, - "hash": "f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837" + "hash": "87c0b050d77309d568a361ae1cd987be53ee30f967cd6726606f20a71e234106" } diff --git a/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json b/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json deleted file mode 100644 index 62e6a58c30..0000000000 --- a/apps/labrinth/.sqlx/query-977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT mod_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n WHERE v.id = ANY($1)\n GROUP BY mod_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 3, - "name": "games", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - null, - null, - null - ] - }, - "hash": "977fbaa018dfb810bec7658111e4dd66540f9d51b167339b41a2f3858843f287" -} diff --git a/apps/labrinth/.sqlx/query-c60b772698eaf55b50c83a1266590628cd8663d14ce8c21466728c556d1bac4a.json b/apps/labrinth/.sqlx/query-c60b772698eaf55b50c83a1266590628cd8663d14ce8c21466728c556d1bac4a.json new file mode 100644 index 0000000000..27ddd612d2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c60b772698eaf55b50c83a1266590628cd8663d14ce8c21466728c556d1bac4a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "c60b772698eaf55b50c83a1266590628cd8663d14ce8c21466728c556d1bac4a" +} diff --git a/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json b/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json deleted file mode 100644 index 4b2b7b2f3a..0000000000 --- a/apps/labrinth/.sqlx/query-d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\t\tUPDATE versions SET components = jsonb_set(\n\t\t\t\t\tCOALESCE(components, '{}'::jsonb), '{minecraft_mod}',\n\t\t\t\t\tjsonb_build_object('game_versions', '[]'::jsonb, 'environment', NULL, 'mrpack_loaders', '[]'::jsonb)\n\t\t\t\t\t\t|| COALESCE(NULLIF(components->'minecraft_mod', 'null'::jsonb), '{}'::jsonb)\n\t\t\t\t\t\t|| $2::jsonb\n\t\t\t\t) WHERE id = $1\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "d287c9f0d52fcc90ebc35e512cac2028b5bcf23aaa68a351ea858d0d56681b4d" -} diff --git a/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json b/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json deleted file mode 100644 index 90f786435a..0000000000 --- a/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, icon_url, banner_url FROM games\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "banner_url", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - true, - true - ] - }, - "hash": "ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee" -} diff --git a/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json b/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json deleted file mode 100644 index a581fcb20d..0000000000 --- a/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT loader_id\n FROM loaders_versions\n WHERE version_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "loader_id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495" -} diff --git a/apps/labrinth/fixtures/dummy_data.sql b/apps/labrinth/fixtures/dummy_data.sql index 0450fc5cea..a23b03d976 100644 --- a/apps/labrinth/fixtures/dummy_data.sql +++ b/apps/labrinth/fixtures/dummy_data.sql @@ -39,21 +39,6 @@ WHERE (l.loader IN ('bukkit', 'waterfall') AND pt.name = 'plugin') OR (l.loader = 'datapack' AND pt.name = 'datapack'); -INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) -SELECT lpt.joining_loader_id, lpt.joining_project_type_id, 1 -FROM loaders_project_types AS lpt -WHERE lpt.joining_loader_id IN (7, 8, 9); - - -INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) -SELECT joining_loader_id, joining_project_type_id, 1 -FROM loaders_project_types -WHERE joining_loader_id = 5; -INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) -SELECT joining_loader_id, joining_project_type_id, 1 -FROM loaders_project_types -WHERE joining_loader_id = 6; - INSERT INTO categories (id, category, project_type) VALUES diff --git a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql index bcaf6a1844..15ecc9b688 100644 --- a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql +++ b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql @@ -166,47 +166,6 @@ COPY public.loaders_project_types (joining_loader_id, joining_project_type_id) F \. -COPY public.loaders_project_types_games (loader_id, project_type_id, game_id) FROM stdin; -1 1 1 -2 1 1 -3 1 1 -4 1 1 -5 1 1 -6 1 1 -7 5 1 -8 1 1 -9 1 1 -10 1 1 -11 1 1 -12 1 1 -13 1 1 -14 1 1 -15 1 1 -16 6 1 -17 6 1 -18 6 1 -19 6 1 -20 1 1 -21 1 1 -22 1 1 -23 2 1 -20 6 1 -9 3 1 -15 3 1 -21 3 1 -13 3 1 -10 3 1 -11 3 1 -8 3 1 -12 3 1 -14 3 1 -24 1 1 -25 1 1 -26 1 1 -27 1 1 -28 1 1 -29 1 1 -\. COPY public.users (id, github_id, username, email, avatar_url, bio, created, role, badges, balance, discord_id, gitlab_id, google_id, steam_id, microsoft_id, password, email_verified, totp_secret, paypal_country, paypal_email, paypal_id, venmo_handle, stripe_customer_id, raw_avatar_url, allow_friend_requests) FROM stdin; diff --git a/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql index 9c49155d83..5443784b9d 100644 --- a/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql +++ b/apps/labrinth/migrations/20261013133423_delete_loader_fields.sql @@ -119,3 +119,5 @@ DROP TABLE loader_fields_loaders; DROP TABLE loader_fields; DROP TABLE loader_field_enum_values; DROP TABLE loader_field_enums; +DROP TABLE loaders_project_types_games; +DROP TABLE games; diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index bf4e936e2a..05d9ae1c23 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -290,168 +290,3 @@ pub async fn ping_minecraft_java_servers( info!("Done pinging Minecraft Java servers"); Ok(()) } - -mod version_updater { - use std::sync::LazyLock; - - use crate::database::PgPool; - use crate::database::models::legacy_loader_fields::MinecraftGameVersion; - use chrono::{DateTime, Utc}; - use serde::Deserialize; - use thiserror::Error; - use tracing::warn; - use xredis::RedisPool; - - #[derive(Deserialize)] - struct InputFormat<'a> { - // latest: LatestFormat, - versions: Vec>, - } - - #[derive(Deserialize)] - struct VersionFormat<'a> { - id: String, - #[serde(rename = "type")] - type_: std::borrow::Cow<'a, str>, - #[serde(rename = "releaseTime")] - release_time: DateTime, - } - - #[derive(Error, Debug)] - pub enum VersionIndexingError { - #[error("Network error while updating game versions list: {0}")] - NetworkError(#[from] reqwest::Error), - #[error("internal error while updating game versions list: {0}")] - InternalError(#[from] eyre::Report), - } - - pub async fn update_versions( - pool: &PgPool, - redis: &RedisPool, - ) -> Result<(), VersionIndexingError> { - let input = reqwest::get( - "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", - ) - .await? - .json::() - .await?; - - let mut skipped_versions_count = 0u32; - - // A list of version names that contains spaces. - // Generated using the command - // ```sh - // curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ - // | jq '[.versions[].id | select(contains(" "))]' - // ``` - const HALL_OF_SHAME: [(&str, &str); 12] = [ - ("1.14.2 Pre-Release 4", "1.14.2-pre4"), - ("1.14.2 Pre-Release 3", "1.14.2-pre3"), - ("1.14.2 Pre-Release 2", "1.14.2-pre2"), - ("1.14.2 Pre-Release 1", "1.14.2-pre1"), - ("1.14.1 Pre-Release 2", "1.14.1-pre2"), - ("1.14.1 Pre-Release 1", "1.14.1-pre1"), - ("1.14 Pre-Release 5", "1.14-pre5"), - ("1.14 Pre-Release 4", "1.14-pre4"), - ("1.14 Pre-Release 3", "1.14-pre3"), - ("1.14 Pre-Release 2", "1.14-pre2"), - ("1.14 Pre-Release 1", "1.14-pre1"), - ("3D Shareware v1.34", "3D-Shareware-v1.34"), - ]; - - /// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, - /// as most of our ordering logic is with DateTime - static HALL_OF_SHAME_2: LazyLock<[(&'static str, DateTime); 4]> = - LazyLock::new(|| { - [ - ( - "1.4.5", - chrono::DateTime::parse_from_rfc3339( - "2012-12-19T22:00:00+00:00", - ) - .unwrap() - .into(), - ), - ( - "1.4.6", - chrono::DateTime::parse_from_rfc3339( - "2012-12-19T22:00:01+00:00", - ) - .unwrap() - .into(), - ), - ( - "1.6.3", - chrono::DateTime::parse_from_rfc3339( - "2013-09-13T10:54:41+00:00", - ) - .unwrap() - .into(), - ), - ( - "13w37b", - chrono::DateTime::parse_from_rfc3339( - "2013-09-13T10:54:42+00:00", - ) - .unwrap() - .into(), - ), - ] - }); - - for version in input.versions.into_iter() { - let mut name = version.id; - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) - { - if let Some((_, alternate)) = - HALL_OF_SHAME.iter().find(|(version, _)| name == *version) - { - name = String::from(*alternate); - } else { - // We'll deal with these manually - skipped_versions_count += 1; - continue; - } - } - - let type_ = match &*version.type_ { - "release" => "release", - "snapshot" => "snapshot", - "old_alpha" => "alpha", - "old_beta" => "beta", - _ => "other", - }; - - MinecraftGameVersion::builder() - .version(&name)? - .version_type(type_)? - .created( - if let Some((_, alternate)) = HALL_OF_SHAME_2 - .iter() - .find(|(version, _)| name == *version) - { - alternate - } else { - &version.release_time - }, - ) - .insert(pool, redis) - .await?; - } - - if skipped_versions_count > 0 { - // This will currently always trigger due to 1.14 pre releases - // and the shareware april fools update. We could set a threshold - // that accounts for those versions and update it whenever we - // manually fix another version. - warn!( - "Skipped {} game versions; check for new versions and add them manually", - skipped_versions_count - ); - } - - Ok(()) - } -} diff --git a/apps/labrinth/src/database/models/game_item.rs b/apps/labrinth/src/database/models/game_item.rs deleted file mode 100644 index 8ed3ce5a91..0000000000 --- a/apps/labrinth/src/database/models/game_item.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::ids::GameId; -use eyre::{Result, WrapErr}; -use futures::TryStreamExt; -use serde::{Deserialize, Serialize}; -use xredis::RedisPool; - -const GAMES_LIST_NAMESPACE: &str = "games:v4"; -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Game { - pub id: GameId, - pub slug: String, - pub name: String, - pub icon_url: Option, - pub banner_url: Option, -} - -impl Game { - pub async fn get_slug<'a, E>( - slug: &str, - exec: E, - redis: &RedisPool, - ) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - Ok(Self::list(exec, redis) - .await - .wrap_err("listing games")? - .into_iter() - .find(|x| x.slug == slug)) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result> - where - E: crate::database::Executor<'a, Database = sqlx::Postgres>, - { - { - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis for cached games")?; - let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); - let cached_games: Option> = redis - .get_deserialized(&key) - .await - .wrap_err("fetching cached games")?; - if let Some(cached_games) = cached_games { - return Ok(cached_games); - } - } - - let result = sqlx::query!( - " - SELECT id, slug, name, icon_url, banner_url FROM games - ", - ) - .fetch(exec) - .map_ok(|x| Game { - id: GameId(x.id), - slug: x.slug, - name: x.name, - icon_url: x.icon_url, - banner_url: x.banner_url, - }) - .try_collect::>() - .await - .wrap_err("fetching games")?; - - let mut redis = redis - .connect() - .await - .wrap_err("connecting to redis to cache games")?; - let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games"); - - redis - .set_serialized(&key, &result, None) - .await - .wrap_err("caching games")?; - - Ok(result) - } -} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 50ceb0a2fb..dc55be6aea 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -291,7 +291,7 @@ db_id_interface!( ); id_type!(CategoryId as i32); -id_type!(GameId as i32); + id_type!(LoaderId as i32); id_type!(NotificationActionId as i32); id_type!(ProjectTypeId as i32); diff --git a/apps/labrinth/src/database/models/loader_item.rs b/apps/labrinth/src/database/models/loader_item.rs index 2f19d50df3..92e6160ca1 100644 --- a/apps/labrinth/src/database/models/loader_item.rs +++ b/apps/labrinth/src/database/models/loader_item.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use xredis::RedisPool; const LOADER_ID: &str = "loader_id:v4"; -const LOADERS_LIST_NAMESPACE: &str = "loaders:v4"; +const LOADERS_LIST_NAMESPACE: &str = "loaders:v5"; #[derive(Clone, Serialize, Deserialize)] pub struct LoaderMetadata { @@ -18,7 +18,7 @@ pub struct Loader { pub loader: String, pub icon: String, pub supported_project_types: Vec, - pub supported_games: Vec, // slugs + pub metadata: LoaderMetadata, } @@ -96,13 +96,11 @@ impl Loader { " SELECT l.id id, l.loader loader, l.icon icon, (l.metadata->>'platform')::boolean AS platform, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types FROM loaders l LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id - LEFT OUTER JOIN games g ON lptg.game_id = g.id + GROUP BY l.id; ", ) @@ -117,9 +115,7 @@ impl Loader { .iter() .map(|x| x.to_string()) .collect(), - supported_games: x - .games - .unwrap_or_default(), + metadata: LoaderMetadata { platform: x.platform, }, diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 9907699ac1..a6fc87c754 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -6,7 +6,7 @@ pub mod collection_item; pub mod delphi_report_item; pub mod flow_item; pub mod friend_item; -pub mod game_item; + pub mod ids; pub mod image_item; pub mod loader_item; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 6b5751da09..04142e7f76 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -20,7 +20,7 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -pub const PROJECTS_NAMESPACE: &str = "projects:v7"; +pub const PROJECTS_NAMESPACE: &str = "projects:v8"; pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v6"; const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4"; @@ -744,22 +744,20 @@ impl DBProject { struct VersionLoaderData { loaders: Vec, project_types: Vec, - games: Vec, + } - let loaders_ptypes_games: DashMap = sqlx::query!( + let loaders_ptypes: DashMap = sqlx::query!( " SELECT DISTINCT mod_id, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types FROM versions v INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id - INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id - INNER JOIN games g ON lptg.game_id = g.id + WHERE v.id = ANY($1) GROUP BY mod_id ", @@ -771,7 +769,7 @@ impl DBProject { let version_loader_data = VersionLoaderData { loaders: m.loaders.unwrap_or_default(), project_types: m.project_types.unwrap_or_default(), - games: m.games.unwrap_or_default(), + }; (project_id, version_loader_data) @@ -836,8 +834,7 @@ impl DBProject { let VersionLoaderData { loaders, mut project_types, - games, - } = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default(); + } = loaders_ptypes.remove(&project_id).map(|x|x.1).unwrap_or_default(); // Each version is a tuple of (DBVersionId, DateTime) let mut versions = versions.remove(&project_id).map(|x| x.1).unwrap_or_default(); versions.sort_by_key(|a| a.1); @@ -898,7 +895,6 @@ impl DBProject { categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), project_types, - games, versions: versions.into_iter().map(|x| x.0).collect(), gallery_items: { gallery.sort_by_key(|a| a.ordering); @@ -1039,7 +1035,7 @@ pub struct ProjectQueryResult { pub additional_categories: Vec, pub versions: Vec, pub project_types: Vec, - pub games: Vec, + pub urls: Vec, pub gallery_items: Vec, pub thread_id: DBThreadId, diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 7df7f39145..cb449f5404 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -18,7 +18,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use tracing::error; -pub const VERSIONS_NAMESPACE: &str = "versions:v6"; +pub const VERSIONS_NAMESPACE: &str = "versions:v7"; const VERSION_FILES_NAMESPACE: &str = "versions_files:v4"; pub async fn cleanup_unused_attribution_files_and_groups( @@ -646,22 +646,20 @@ impl DBVersion { struct VersionLoaderData { loaders: Vec, project_types: Vec, - games: Vec, + } - let loaders_ptypes_games: DashMap = sqlx::query!( + let loaders_ptypes: DashMap = sqlx::query!( " SELECT DISTINCT version_id, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, - ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types FROM versions v INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id - INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id - INNER JOIN games g ON lptg.game_id = g.id + WHERE v.id = ANY($1) GROUP BY version_id ", @@ -673,7 +671,7 @@ impl DBVersion { let version_loader_data = VersionLoaderData { loaders: m.loaders.unwrap_or_default(), project_types: m.project_types.unwrap_or_default(), - games: m.games.unwrap_or_default(), + }; (version_id,version_loader_data) @@ -819,8 +817,7 @@ impl DBVersion { let VersionLoaderData { loaders, project_types, - games, - } = loaders_ptypes_games.remove(&version_id).map(|x|x.1).unwrap_or_default(); + } = loaders_ptypes.remove(&version_id).map(|x|x.1).unwrap_or_default(); let files = files.remove(&version_id).map(|x|x.1).unwrap_or_default(); let hashes = hashes.remove(&version_id).map(|x|x.1).unwrap_or_default(); let dependencies = dependencies.remove(&version_id).map(|x|x.1).unwrap_or_default(); @@ -895,7 +892,6 @@ impl DBVersion { }, loaders, project_types, - games, dependencies, // TODO populate components: exp::VersionQuery::default(), @@ -1070,7 +1066,7 @@ pub struct VersionQueryResult { pub files: Vec, pub loaders: Vec, pub project_types: Vec, - pub games: Vec, + pub dependencies: Vec, pub components: exp::VersionQuery, } diff --git a/apps/labrinth/src/database/redis/mod.rs b/apps/labrinth/src/database/redis/mod.rs index c77b924f98..267564e039 100644 --- a/apps/labrinth/src/database/redis/mod.rs +++ b/apps/labrinth/src/database/redis/mod.rs @@ -171,7 +171,6 @@ mod tests { loader: "paper".to_string(), icon: "icon".to_string(), supported_project_types: vec!["plugin".to_string()], - supported_games: vec!["minecraft-java".to_string()], metadata: LoaderMetadata { platform }, }; let round_tripped = postcard_round_trip(&loader); @@ -183,7 +182,6 @@ mod tests { round_tripped.supported_project_types, loader.supported_project_types ); - assert_eq!(round_tripped.supported_games, loader.supported_games); assert_eq!(round_tripped.metadata.platform, platform); } } @@ -195,7 +193,6 @@ mod tests { files: Vec::new(), loaders: vec!["fabric".to_string()], project_types: vec!["mod".to_string()], - games: vec!["minecraft-java".to_string()], dependencies: Vec::new(), components: exp::VersionQuery::default(), }; @@ -207,7 +204,6 @@ mod tests { additional_categories: Vec::new(), versions: vec![DBVersionId(1)], project_types: vec!["mod".to_string()], - games: vec!["minecraft-java".to_string()], urls: Vec::new(), gallery_items: Vec::new(), thread_id: DBThreadId(1), diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 1eebd87c72..dd247ce43b 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -147,6 +147,17 @@ fn minecraft_version_fields( fields } +fn minecraft_games(java: bool, bedrock: bool) -> Vec { + let mut games = Vec::new(); + if java { + games.push("minecraft-java".to_owned()); + } + if bedrock { + games.push("minecraft-bedrock".to_owned()); + } + games +} + impl From for Project { fn from(data: ProjectQueryResult) -> Self { let mut fields: HashMap> = @@ -167,11 +178,15 @@ impl From for Project { } } let m = data.inner; + let games = minecraft_games( + !m.loaders.is_empty() || m.components.minecraft_java_server.is_some(), + m.components.minecraft_bedrock_server.is_some(), + ); Self { id: m.id.into(), slug: m.slug, project_types: data.project_types, - games: data.games, + games, team_id: m.team_id.into(), organization: m.organization_id.map(|i| i.into()), name: m.name, @@ -797,7 +812,7 @@ pub struct Version { pub version_number: String, /// Project types for which this version is compatible with, extracted from Loader pub project_types: Vec, - /// Games for which this version is compatible with, extracted from Loader/Project types + /// Minecraft games supported by this version's components and loaders pub games: Vec, /// The changelog for this version of the project. #[serde(skip_serializing_if = "Option::is_none")] @@ -861,6 +876,10 @@ impl From for Version { }) .unwrap_or_default(); let v = data.inner; + let games = minecraft_games( + !data.loaders.is_empty() || v.components.minecraft_java_server.is_some(), + false, + ); Version { id: v.id.into(), project_id: v.project_id.into(), @@ -869,7 +888,7 @@ impl From for Version { name: v.name, version_number: v.version_number, project_types: data.project_types, - games: data.games, + games, changelog: Some(v.changelog), date_published: v.date_published, downloads: v.downloads as u32, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 6e90c00451..951dc854d4 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -175,7 +175,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { reports::report_edit_route, reports::report_delete_route, statistics::get_stats_route, - tags::games_list_route, tags::category_list_route, tags::loader_list_route, tags::loader_fields_list_route, diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index 5034a27ed0..828134ecdc 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use super::ApiError; use crate::database::models::categories::{Category, ProjectType, ReportType}; -use crate::database::models::game_item::Game; use crate::database::models::loader_item::{Loader, LoaderMetadata}; use crate::models::exp::minecraft::{Environment, MrpackLoader}; use crate::models::link_platform::LinkPlatform; @@ -24,7 +23,6 @@ use serde_json::Value; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(category_list_route) .service(loader_list_route) - .service(games_list_route) .service(loader_fields_list_route) .service(license_list_route) .service(license_text_route) @@ -33,42 +31,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(project_type_list_route); } -#[derive(serde::Serialize, serde::Deserialize)] -pub struct GameData { - pub slug: String, - pub name: String, - pub icon: Option, - pub banner: Option, -} - -#[utoipa::path(tag = "tags", responses((status = OK)))] -#[get("/games")] -pub async fn games_list_route( - pool: web::Data, - redis: web::Data, -) -> Result { - games_list(pool, redis).await -} - -pub async fn games_list( - pool: web::Data, - redis: web::Data, -) -> Result { - let results = Game::list(&**pool, &redis) - .await - .wrap_internal_err("fetching game from Redis")? - .into_iter() - .map(|x| GameData { - slug: x.slug, - name: x.name, - icon: x.icon_url, - banner: x.banner_url, - }) - .collect::>(); - - Ok(HttpResponse::Ok().json(results)) -} - #[derive(serde::Serialize, serde::Deserialize)] pub struct CategoryData { pub icon: String, @@ -135,25 +97,18 @@ pub async fn loader_list( let mut results = loaders .into_iter() .map(|loader| { - let mut supported_fields = Vec::new(); - if loader - .supported_games - .iter() - .any(|game| game == "minecraft-java") - { - supported_fields.extend([ - "game_versions".to_owned(), - "environment".to_owned(), - ]); - if loader.loader == "mrpack" { - supported_fields.push("mrpack_loaders".to_owned()); - } - } + let mut supported_fields = vec![ + "game_versions".to_owned(), + "environment".to_owned(), + ]; + if loader.loader == "mrpack" { + supported_fields.push("mrpack_loaders".to_owned()); + } LoaderData { icon: loader.icon, name: loader.loader, supported_project_types: loader.supported_project_types, - supported_games: loader.supported_games, + supported_games: vec!["minecraft-java".to_owned()], supported_fields, metadata: loader.metadata, } diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index b1c906cc7d..9acb952209 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -493,6 +493,7 @@ pub async fn version_edit_helper( .await .wrap_internal_err("starting database transaction")?; let (version_item, user) = version_edit_in_transaction( + minecraft_meta.clone(), req, info, pool, @@ -527,6 +528,7 @@ pub async fn version_edit_helper( /// The caller must finalize and invalidate version caches after committing, and /// roll back the transaction or a savepoint if the edit fails. pub(crate) async fn version_edit_in_transaction( + minecraft_meta: web::Data, req: HttpRequest, info: (VersionId,), pool: web::Data, @@ -746,7 +748,7 @@ pub(crate) async fn version_edit_in_transaction( )? }; let all_loaders = database::models::loader_item::Loader::list( - &mut transaction, + &mut *transaction, &redis, ) .await @@ -789,7 +791,7 @@ pub(crate) async fn version_edit_in_transaction( version_id as database::models::ids::DBVersionId, sqlx::types::Json(component) as _ ) - .execute(&mut transaction) + .execute(&mut *transaction) .await .wrap_internal_err("updating Minecraft version component")?; } diff --git a/apps/labrinth/src/test/api_v3/tags.rs b/apps/labrinth/src/test/api_v3/tags.rs index 70e7120c18..14b2b1043f 100644 --- a/apps/labrinth/src/test/api_v3/tags.rs +++ b/apps/labrinth/src/test/api_v3/tags.rs @@ -1,4 +1,4 @@ -use crate::routes::v3::tags::{GameData, LoaderData}; +use crate::routes::v3::tags::LoaderData; use crate::routes::v3::tags::{CategoryData, MinecraftTagValue}; use actix_http::StatusCode; use actix_web::{ @@ -86,17 +86,4 @@ impl ApiV3 { test::read_body_json(resp).await } - async fn get_games(&self) -> ServiceResponse { - let req = TestRequest::get() - .uri("/v3/games") - .append_pat(ADMIN_USER_PAT) - .to_request(); - self.call(req).await - } - - pub async fn get_games_deserialized(&self) -> Vec { - let resp = self.get_games().await; - assert_status!(&resp, StatusCode::OK); - test::read_body_json(resp).await - } } diff --git a/apps/labrinth/src/test/dummy_data.rs b/apps/labrinth/src/test/dummy_data.rs index bd49c1eac2..130a62f9d8 100644 --- a/apps/labrinth/src/test/dummy_data.rs +++ b/apps/labrinth/src/test/dummy_data.rs @@ -25,7 +25,7 @@ use super::{ use super::{database::USER_USER_ID, get_json_val_str}; -pub const DUMMY_DATA_UPDATE: i64 = 12; +pub const DUMMY_DATA_UPDATE: i64 = 13; pub const DUMMY_CATEGORIES: &[&str] = &[ "combat", diff --git a/apps/labrinth/tests/games.rs b/apps/labrinth/tests/games.rs deleted file mode 100644 index 449f0035d0..0000000000 --- a/apps/labrinth/tests/games.rs +++ /dev/null @@ -1,29 +0,0 @@ -// TODO: fold this into loader_fields.rs or tags.rs of other v3 testing PR - -use common::{ - api_v3::ApiV3, - environment::{TestEnvironment, with_test_environment}, -}; - -pub mod common; - -#[actix_rt::test] -async fn get_games() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let api = test_env.api; - - let games = api.get_games_deserialized().await; - - // There should be 2 games in the dummy data - assert_eq!(games.len(), 2); - assert_eq!(games[0].name, "minecraft-java"); - assert_eq!(games[1].name, "minecraft-bedrock"); - - assert_eq!(games[0].slug, "minecraft-java"); - assert_eq!(games[1].slug, "minecraft-bedrock"); - }, - ) - .await; -} From ef7debe271dae2f8282fe26f1c0d683ad5d7b578 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:43:05 +0100 Subject: [PATCH 10/13] fix tombi --- apps/labrinth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index a03bb67b47..2216d5f8ab 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -123,9 +123,9 @@ sqlx = { workspace = true, features = [ sqlx-tracing = { workspace = true, features = ["postgres"] } strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } +toasty = { workspace = true, features = ["postgresql"] } tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } tokio-stream = { workspace = true } -toasty = { workspace = true, features = ["postgresql"] } totp-rs = { workspace = true, features = ["gen_secret"] } tracing = { workspace = true } tracing-actix-web = { workspace = true } From 7db975780d6cb574504a5ba60bf4bd16bca58b1c Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:53:39 +0100 Subject: [PATCH 11/13] impl version component fetching --- .../src/database/models/version_item.rs | 34 +++++++---- apps/labrinth/src/models/exp/component.rs | 6 +- apps/labrinth/src/models/exp/version.rs | 59 +++++++++++++++++++ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index cb449f5404..106b821f6c 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -18,7 +18,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use tracing::error; -pub const VERSIONS_NAMESPACE: &str = "versions:v7"; +pub const VERSIONS_NAMESPACE: &str = "versions:v8"; const VERSION_FILES_NAMESPACE: &str = "versions_files:v4"; pub async fn cleanup_unused_attribution_files_and_groups( @@ -798,7 +798,7 @@ impl DBVersion { .await .unwrap_or_default(); - let res = sqlx::query!( + let version_rows = sqlx::query!( r#" SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number, v.changelog changelog, v.date_published date_published, v.downloads downloads, @@ -809,10 +809,23 @@ impl DBVersion { "#, &version_ids ) - .fetch(&mut exec) - .map_err(eyre::Report::from) + .fetch_all(&mut exec) + .await + .wrap_err("fetching version rows")?; + + let version_components = version_rows.iter() + .map(|row| (DBVersionId(row.id).into(), &row.components.0)) + .collect::>(); + let version_query_context = exp::version::fetch_query_context( + &version_components, + &mut exec, + redis, + ) + .await + .wrap_err("fetching version query context")?; + + let res = version_rows.into_iter() .try_fold(DashMap::new(), |acc, v| { - let result = { let version_id = DBVersionId(v.id); let VersionLoaderData { loaders, @@ -836,6 +849,9 @@ impl DBVersion { .collect_vec(); let components_serial = v.components.0; + let components = components_serial.clone() + .into_query(version_id.into(), &version_query_context) + .wrap_err("failed to populate query components")?; let query_version = VersionQueryResult { inner: DBVersion { id: DBVersionId(v.id), @@ -893,17 +909,13 @@ impl DBVersion { loaders, project_types, dependencies, - // TODO populate - components: exp::VersionQuery::default(), + components, }; acc.insert(v.id, query_version); eyre::Ok(acc) - }; - async move { result } }) - .await - .wrap_err("fetching versions")?; + .wrap_err("populating versions")?; eyre::Ok(res) }, diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index 2be0c8026f..1fcd3b3e4d 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -3,9 +3,9 @@ macro_rules! define { (@entity) => { $crate::models::ids::ProjectId }; (@entity version) => { $crate::models::ids::VersionId }; (@context) => { $crate::models::exp::project::ProjectQueryContext }; - (@context version) => { () }; + (@context version) => { $crate::models::exp::version::VersionQueryContext }; (@requirements) => { $crate::models::exp::project::ProjectQueryRequirements }; - (@requirements version) => { () }; + (@requirements version) => { $crate::models::exp::version::VersionQueryRequirements }; ( $(#[$meta:meta])* $vis:vis struct $name:ident $( : $entity:ident )? { @@ -213,7 +213,7 @@ pub trait ComponentQuery: Sized { /// What information does this query type require from the database to /// populate itself (excluding the [`ComponentQuery::Component`])? /// - /// For example, if the [`ComponentQuery::Component`] has a projecet ID, + /// For example, if the [`ComponentQuery::Component`] has a project ID, /// this will add the project ID to `requirements`. This will require the /// fetcher to also fetch this project ID, which will be available in the /// [`ComponentQuery::Context`] during [`ComponentQuery::populate`]. diff --git a/apps/labrinth/src/models/exp/version.rs b/apps/labrinth/src/models/exp/version.rs index a8fe2c65b0..338d9e898f 100644 --- a/apps/labrinth/src/models/exp/version.rs +++ b/apps/labrinth/src/models/exp/version.rs @@ -4,6 +4,9 @@ use crate::models::exp::{ minecraft, }; +use crate::models::exp::component::ComponentQuery; +use crate::models::ids::VersionId; +use eyre::{Result, WrapErr}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use validator::Validate; @@ -54,6 +57,45 @@ macro_rules! define_version_components { )* } + impl VersionSerial { + pub fn collect_query_requirements( + &self, + version_id: VersionId, + requirements: &mut VersionQueryRequirements, + ) { + $( + if let Some(component) = &self.$field_name { + <<$ty as Component>::Query as ComponentQuery>::collect_requirements( + component, + version_id, + requirements, + ); + } + )* + } + + pub fn into_query( + self, + version_id: VersionId, + context: &VersionQueryContext, + ) -> Result { + Ok(VersionQuery { + $( + $field_name: match self.$field_name { + Some(serial) => <$ty as Component>::Query::populate( + serial, + version_id, + context, + ) + .map(Some) + .wrap_err(concat!("failed to populate `", stringify!($ty), "`"))?, + None => None, + }, + )* + }) + } + } + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct VersionCreate { #[validate(nested)] @@ -102,6 +144,23 @@ define_version_components![ (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion, ]; +#[derive(Default)] +pub struct VersionQueryRequirements {} + +pub struct VersionQueryContext {} + +pub async fn fetch_query_context( + versions: &[(VersionId, &VersionSerial)], + _db: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + _redis: &xredis::RedisPool, +) -> Result { + let mut requirements = VersionQueryRequirements::default(); + for (version_id, version) in versions { + version.collect_query_requirements(*version_id, &mut requirements); + } + Ok(VersionQueryContext {}) +} + component::relations! { pub static VERSION_COMPONENT_RELATIONS: VersionComponentKind = { use VersionComponentKind::*; From bc76386dacf0c90a0e7871b201cb90e1116a7042 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:57:31 +0100 Subject: [PATCH 12/13] fix how we look up loader during edit --- apps/labrinth/src/routes/v3/versions.rs | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 9acb952209..a08b74f906 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -753,18 +753,27 @@ pub(crate) async fn version_edit_in_transaction( ) .await .wrap_internal_err("fetching loaders")?; + let loader_ids = if let Some(ids) = &new_loader_ids { + ids.clone() + } else { + sqlx::query!( + r#" + SELECT loader_id + FROM loaders_versions + WHERE version_id = $1 + "#, + version_id as database::models::ids::DBVersionId, + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching version loaders from database")? + .into_iter() + .map(|row| database::models::ids::LoaderId(row.loader_id)) + .collect::>() + }; let selected_loaders = all_loaders .into_iter() - .filter(|loader| { - if let Some(ids) = &new_loader_ids { - ids.contains(&loader.id) - } else { - version_item - .loaders - .iter() - .any(|name| name == &loader.loader) - } - }) + .filter(|loader| loader_ids.contains(&loader.id)) .collect::>(); if edits_mrpack_loaders && !selected_loaders From 674c6c4e14d6523c402ed46740a1cbb00990faba Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:24:40 +0100 Subject: [PATCH 13/13] update sqlx prepare --- ...64b885b55320c2213ca2b5c56544204745495.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json diff --git a/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json b/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json new file mode 100644 index 0000000000..a581fcb20d --- /dev/null +++ b/apps/labrinth/.sqlx/query-fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT loader_id\n FROM loaders_versions\n WHERE version_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "loader_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd32658580c72883f746369d2a064b885b55320c2213ca2b5c56544204745495" +}