MC versioning overhaul: calendar scheme + DB-backed versions (REQ-2026-04-16-mc-versioning)

Migration 145: mc_versions table with java_version, paper/forge/neoforge/fabric
support flags, seeded with 11 known versions for immediate use.

New: src/services/mcVersionSync.js
- javaVersionForMC() handles both 1.x.y legacy and 26.x.y calendar schemes
- Daily 3AM pg-boss cron fetches Paper API + Modrinth /tag/game_version, upserts
  mc_versions with correct loader flags and Java version mapping
- getVersions(filter) for DB-backed lookups with loader filtering
- semver.rcompare for proper version sorting across both schemes

Routes: GET /admin/modpack-installer/mc-versions — JSON endpoint for dynamic
version dropdowns, filterable by loader (paper/forge/neoforge/fabric), with
hardcoded fallback if DB is unavailable.

Views updated:
- _vanilla_form.ejs: MC version dropdown loaded dynamically (Paper-only filter),
  Java auto-select reads java_version from DB row
- index.ejs: search filter version dropdown loaded dynamically from mc_versions
- _pack_details.ejs: client-side Java logic handles major >= 26

index.js: registers mc-version-sync cron via pg-boss after queue init.
package.json: added semver ^7.6.3.
ACTIVE_CONTEXT updated.
This commit is contained in:
Claude Code
2026-04-16 02:40:50 -05:00
parent ed1ae82825
commit e891a304e0
10 changed files with 297 additions and 30 deletions

View File

@@ -5,7 +5,7 @@
Local Nitro (Windows 11) at `C:\Users\mkrau\firefrost-services`. Git remote: Gitea. Identity: `Claude Code <claude@firefrostgaming.com>`.
## Current Focus
Bridge queue empty. Seven features shipped tonight, all pending deploy by Michael.
Bridge queue empty. Eight features shipped tonight, all pending deploy by Michael.
## Session 2026-04-16
@@ -35,6 +35,15 @@ Bridge queue empty. Seven features shipped tonight, all pending deploy by Michae
- `index.ejs`: "New Vanilla / Paper Server" button loads vanilla form directly (skips pack search)
- No migrations. **Deploy:** standard restart only, ensure `standard-plugins/1.21.1/` populated in NextCloud before first vanilla install
- **MC Versioning Overhaul** (REQ-2026-04-16-mc-versioning)
- Migration 145: `mc_versions` table with loader support flags, seeded with 11 known versions
- `mcVersionSync.js`: new `javaVersionForMC()` handles both legacy 1.x.y and calendar 26.x.y schemes; daily 3AM pg-boss cron syncs from Paper API + Modrinth `/tag/game_version`; `getVersions(filter)` for DB-backed lookups
- `/mc-versions` endpoint returns cached versions (filterable by loader), with hardcoded fallback
- `_vanilla_form.ejs` + `index.ejs` search filter: replaced hardcoded version dropdowns with dynamic fetch from mc_versions
- `_pack_details.ejs`: updated client-side Java auto-select for calendar versioning
- Added `semver ^7.6.3` to package.json
- **Deploy:** `npm install` (semver) + run migration 145 + restart
- **Discord Action Log — Issue #1** (`49f8f79`, +263 lines)
- Migration 142: `discord_action_log` table
- `discordActionLog.js` service (silent-fail logAction)

View File

@@ -0,0 +1,28 @@
-- Migration 145: mc_versions table (REQ-2026-04-16-mc-versioning)
-- Caches available MC versions with loader support flags.
-- Refreshed daily at 3AM via pg-boss cron job.
CREATE TABLE IF NOT EXISTS mc_versions (
version VARCHAR(20) PRIMARY KEY,
java_version INT NOT NULL DEFAULT 21,
paper_supported BOOLEAN DEFAULT FALSE,
forge_supported BOOLEAN DEFAULT FALSE,
neoforge_supported BOOLEAN DEFAULT FALSE,
fabric_supported BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Seed with known versions so installer works before first cron run
INSERT INTO mc_versions (version, java_version, paper_supported, forge_supported, neoforge_supported, fabric_supported) VALUES
('26.1.2', 21, true, false, true, true),
('26.1.1', 21, true, false, true, true),
('26.1', 21, true, false, true, true),
('1.21.4', 21, true, false, true, true),
('1.21.1', 21, true, true, true, true),
('1.20.4', 17, true, true, false, true),
('1.20.1', 17, true, true, false, true),
('1.19.4', 17, true, true, false, true),
('1.19.2', 17, true, true, false, true),
('1.18.2', 17, true, true, false, true),
('1.16.5', 8, true, true, false, true)
ON CONFLICT (version) DO NOTHING;

View File

@@ -25,6 +25,7 @@
"node-cron": "^3.0.3",
"passport": "^0.7.0",
"pg-boss": "^10.1.5",
"semver": "^7.6.3",
"passport-discord": "^0.1.4",
"pg": "^8.11.3",
"socket.io-client": "^4.7.5",

View File

@@ -161,6 +161,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
// Initialize pg-boss job queue (Task #101 — Modpack Installer)
const PgBoss = require('pg-boss');
const { handleInstallJob } = require('./services/modpackInstaller');
const { registerCronJob: registerVersionSync } = require('./services/mcVersionSync');
(async () => {
try {
const boss = new PgBoss({
@@ -174,6 +175,7 @@ const { handleInstallJob } = require('./services/modpackInstaller');
await boss.work('modpack-installs', { teamConcurrency: 2 }, handleInstallJob);
app.locals.pgBoss = boss;
console.log('✅ pg-boss job queue initialized (modpack-installs concurrency: 2)');
await registerVersionSync(boss);
} catch (err) {
console.error('⚠️ pg-boss init failed (installer will not work):', err.message);
}

View File

@@ -18,11 +18,12 @@ const axios = require('axios');
const db = require('../../database');
const { getProvider, listProviders } = require('../../services/providerApi');
const { estimateRam, slugify } = require('../../services/modpackInstaller');
const { javaVersionForMC, getVersions: getMCVersions } = require('../../services/mcVersionSync');
const PANEL_URL = process.env.PANEL_URL || 'https://panel.firefrostgaming.com';
const PANEL_ADMIN_KEY = process.env.PANEL_ADMIN_KEY || '';
// Aikar G1GC flags template — {RAM} replaced with allocation in MB
// Aikar G1GC flags template
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' +
'-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' +
'-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' +
@@ -32,15 +33,6 @@ const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMill
'-XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs ' +
'-Daikars.new.flags=true';
function javaVersionForMC(mcVersion) {
if (!mcVersion) return 21;
const parts = mcVersion.split('.').map(Number);
const minor = parts[1] || 0;
if (minor <= 16) return 8;
if (minor <= 20) return 17;
return 21;
}
// ─── Node resource usage (JSON for form) ────────────────────────────────────
router.get('/node-info', async (req, res) => {
try {
@@ -99,6 +91,29 @@ router.get('/next-port', async (req, res) => {
}
});
// ─── MC versions (JSON for dynamic dropdowns) ──────────────────────────────
router.get('/mc-versions', async (req, res) => {
try {
const filter = {};
if (req.query.paper) filter.paper = true;
if (req.query.forge) filter.forge = true;
if (req.query.neoforge) filter.neoforge = true;
if (req.query.fabric) filter.fabric = true;
const versions = await getMCVersions(filter);
res.json(versions);
} catch (err) {
console.error('[Installer] mc-versions error:', err.message);
// Fallback to hardcoded list if DB is down
res.json([
{ version: '26.1.2', java_version: 21 },
{ version: '1.21.1', java_version: 21 },
{ version: '1.20.1', java_version: 17 },
{ version: '1.18.2', java_version: 17 },
{ version: '1.16.5', java_version: 8 }
]);
}
});
// ─── Main page ──────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
res.render('admin/modpack-installer/index', {

View File

@@ -0,0 +1,181 @@
/**
* MC Version Sync — daily refresh of mc_versions table from Paper + Modrinth APIs.
* REQ-2026-04-16-mc-versioning
*
* Runs as a pg-boss cron job at 3AM daily. Also callable on-demand from admin.
*
* Data sources:
* 1. Paper API → paper_supported versions
* 2. Modrinth /tag/game_version → all MC versions + loader tags
*
* Java version mapping handles both legacy (1.x.y) and new calendar (26.x.y) schemes.
*/
const axios = require('axios');
const semver = require('semver');
const db = require('../database');
/**
* Determine Java version for any MC version string.
* Handles both 1.x.y legacy and 26.x.y calendar scheme.
*/
function javaVersionForMC(mcVersion) {
if (!mcVersion) return 21;
const major = parseInt(mcVersion.split('.')[0]);
// New 2026+ calendar scheme — all Java 21
if (major >= 26) return 21;
// Legacy 1.x.y scheme
if (major === 1) {
const minor = parseInt(mcVersion.split('.')[1]) || 0;
if (minor >= 21) return 21;
if (minor >= 17) return 17;
return 8;
}
return 21; // Frostwall fallback
}
/**
* Fetch Paper-supported versions from Paper API.
*/
async function fetchPaperVersions() {
try {
const resp = await axios.get('https://api.papermc.io/v2/projects/paper', { timeout: 10000 });
return new Set(resp.data.versions || []);
} catch (err) {
console.error('[MCVersionSync] Paper API error:', err.message);
return new Set();
}
}
/**
* Fetch all MC versions + loader tags from Modrinth.
*/
async function fetchModrinthVersions() {
try {
const resp = await axios.get('https://api.modrinth.com/v2/tag/game_version', {
headers: { 'User-Agent': 'FirefrostGaming/Arbiter (contact@firefrostgaming.com)' },
timeout: 10000
});
// Filter to releases only (skip snapshots)
return (resp.data || []).filter(v => v.version_type === 'release');
} catch (err) {
console.error('[MCVersionSync] Modrinth API error:', err.message);
return [];
}
}
/**
* Sort MC version strings properly — handles both 1.x.y and 26.x.y.
* Uses semver.rcompare where possible, falls back to string compare.
*/
function sortVersions(versions) {
return versions.sort((a, b) => {
// Coerce to semver-compatible (pad to 3 parts)
const pad = v => {
const parts = v.split('.');
while (parts.length < 3) parts.push('0');
return parts.join('.');
};
const sa = semver.valid(semver.coerce(pad(a)));
const sb = semver.valid(semver.coerce(pad(b)));
if (sa && sb) return semver.rcompare(sa, sb);
return b.localeCompare(a);
});
}
/**
* Run the full sync. Upserts mc_versions from Paper + Modrinth data.
* Returns { synced, paperCount, total }.
*/
async function syncVersions() {
console.log('[MCVersionSync] Starting version sync...');
const [paperVersions, modrinthVersions] = await Promise.all([
fetchPaperVersions(),
fetchModrinthVersions()
]);
// Build unified version set
const allVersions = new Set();
for (const v of paperVersions) allVersions.add(v);
for (const v of modrinthVersions) allVersions.add(v.version);
// We only care about "major" releases (x.y.z or x.y), skip pre-releases/snapshots
const releaseVersions = [...allVersions].filter(v => /^\d+\.\d+(\.\d+)?$/.test(v));
const sorted = sortVersions(releaseVersions);
// Determine loader support from Modrinth tags (best-effort)
const modrinthMap = new Map();
for (const v of modrinthVersions) {
modrinthMap.set(v.version, v);
}
let synced = 0;
for (const version of sorted) {
const java = javaVersionForMC(version);
const paper = paperVersions.has(version);
// Modrinth doesn't give per-version loader flags in the tag endpoint,
// so we infer from version range heuristics
const major = parseInt(version.split('.')[0]);
const minor = parseInt(version.split('.')[1]) || 0;
const forge = major === 1 && minor >= 7;
const neoforge = (major === 1 && minor >= 21) || major >= 26;
const fabric = (major === 1 && minor >= 14) || major >= 26;
await db.query(`
INSERT INTO mc_versions (version, java_version, paper_supported, forge_supported, neoforge_supported, fabric_supported, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (version) DO UPDATE SET
java_version = EXCLUDED.java_version,
paper_supported = EXCLUDED.paper_supported,
forge_supported = EXCLUDED.forge_supported,
neoforge_supported = EXCLUDED.neoforge_supported,
fabric_supported = EXCLUDED.fabric_supported,
updated_at = NOW()
`, [version, java, paper, forge, neoforge, fabric]);
synced++;
}
console.log(`[MCVersionSync] Done. Synced ${synced} versions (${paperVersions.size} Paper-supported).`);
return { synced, paperCount: paperVersions.size, total: sorted.length };
}
/**
* Get cached versions from DB, optionally filtered by loader.
*/
async function getVersions(filter = {}) {
let where = 'WHERE 1=1';
if (filter.paper) where += ' AND paper_supported = true';
if (filter.forge) where += ' AND forge_supported = true';
if (filter.neoforge) where += ' AND neoforge_supported = true';
if (filter.fabric) where += ' AND fabric_supported = true';
const result = await db.query(`
SELECT * FROM mc_versions ${where}
ORDER BY
CASE WHEN version ~ '^[0-9]+\\.' THEN CAST(SPLIT_PART(version, '.', 1) AS INT) ELSE 0 END DESC,
CASE WHEN version ~ '^[0-9]+\\.[0-9]+' THEN CAST(SPLIT_PART(version, '.', 2) AS INT) ELSE 0 END DESC,
CASE WHEN version ~ '^[0-9]+\\.[0-9]+\\.[0-9]+' THEN CAST(SPLIT_PART(version, '.', 3) AS INT) ELSE 0 END DESC
`);
return result.rows;
}
/**
* Register the 3AM daily cron job with pg-boss.
*/
async function registerCronJob(boss) {
try {
await boss.schedule('mc-version-sync', '0 3 * * *');
await boss.work('mc-version-sync', async () => {
await syncVersions();
});
console.log('✅ MC version sync cron registered (daily 3AM)');
} catch (err) {
console.error('[MCVersionSync] Failed to register cron:', err.message);
}
}
module.exports = { javaVersionForMC, syncVersions, getVersions, registerCronJob };

View File

@@ -190,9 +190,10 @@
function onVersionChange(sel) {
var mc = sel.options[sel.selectedIndex].dataset.mc || '';
var parts = mc.split('.').map(Number);
var minor = parts[1] || 0;
var java = minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
var major = parseInt(mc.split('.')[0]) || 0;
var minor = parseInt(mc.split('.')[1]) || 0;
// Calendar scheme (26+) → Java 21; Legacy 1.x → 8/17/21
var java = major >= 26 ? 21 : minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
document.getElementById('dd-java').value = java;
}

View File

@@ -21,17 +21,12 @@
<input type="hidden" name="spawnType" value="vanilla">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- MC version -->
<!-- MC version (loaded dynamically from mc_versions table, Paper-only) -->
<div>
<label class="block text-sm font-medium mb-1">Minecraft Version</label>
<select name="versionId" id="vf-mcversion" required onchange="vfUpdateJava(this)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
<option value="1.21.1" data-mc="1.21.1" selected>1.21.1 (latest)</option>
<option value="1.20.4" data-mc="1.20.4">1.20.4</option>
<option value="1.20.1" data-mc="1.20.1">1.20.1</option>
<option value="1.19.4" data-mc="1.19.4">1.19.4</option>
<option value="1.18.2" data-mc="1.18.2">1.18.2</option>
<option value="1.16.5" data-mc="1.16.5">1.16.5</option>
<option value="">Loading versions...</option>
</select>
</div>
@@ -195,6 +190,26 @@
</div>
<script>
// Load MC versions dynamically (Paper-supported only)
fetch('/admin/modpack-installer/mc-versions?paper=1', { headers: { 'HX-Request': 'true' } })
.then(function(r) { return r.json(); })
.then(function(versions) {
var sel = document.getElementById('vf-mcversion');
sel.innerHTML = '';
versions.forEach(function(v, i) {
var opt = document.createElement('option');
opt.value = v.version;
opt.dataset.mc = v.version;
opt.dataset.java = v.java_version;
opt.textContent = v.version + (i === 0 ? ' (latest)' : '');
if (i === 0) opt.selected = true;
sel.appendChild(opt);
});
if (versions.length > 0) {
document.getElementById('vf-java').value = versions[0].java_version;
}
});
// Load node stats
fetch('/admin/modpack-installer/node-info', { headers: { 'HX-Request': 'true' } })
.then(function(r) { return r.json(); })
@@ -220,9 +235,15 @@
}
function vfUpdateJava(sel) {
var mc = sel.options[sel.selectedIndex].dataset.mc || '';
var parts = mc.split('.').map(Number);
var minor = parts[1] || 0;
document.getElementById('vf-java').value = minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
var opt = sel.options[sel.selectedIndex];
if (opt && opt.dataset.java) {
document.getElementById('vf-java').value = opt.dataset.java;
} else {
// Fallback: derive from version string
var mc = (opt && opt.dataset.mc) || '';
var major = parseInt(mc.split('.')[0]) || 0;
var minor = parseInt(mc.split('.')[1]) || 0;
document.getElementById('vf-java').value = major >= 26 ? 21 : minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
}
}
</script>

View File

@@ -52,11 +52,7 @@
onkeydown="if(event.key==='Enter')doSearch()">
<select id="filter-version" class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white">
<option value="">Any MC version</option>
<option value="1.21.1">1.21.1</option>
<option value="1.20.1">1.20.1</option>
<option value="1.19.2">1.19.2</option>
<option value="1.18.2">1.18.2</option>
<option value="1.16.5">1.16.5</option>
<!-- Populated dynamically from mc_versions table -->
</select>
<button onclick="doSearch()" class="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded text-sm">Search</button>
</div>
@@ -69,6 +65,19 @@
<script>
var selectedProvider = '';
// Load MC versions for search filter dropdown
fetch('/admin/modpack-installer/mc-versions', { headers: { 'HX-Request': 'true' } })
.then(function(r) { return r.json(); })
.then(function(versions) {
var sel = document.getElementById('filter-version');
versions.slice(0, 20).forEach(function(v) {
var opt = document.createElement('option');
opt.value = v.version;
opt.textContent = v.version;
sel.appendChild(opt);
});
});
function selectProvider(id) {
selectedProvider = id;
document.querySelectorAll('.provider-card').forEach(function(el) {