diff --git a/apps/server/drizzle/0039_mod_registry_versions_pin_failed_at.sql b/apps/server/drizzle/0039_mod_registry_versions_pin_failed_at.sql new file mode 100644 index 0000000..0573bf9 --- /dev/null +++ b/apps/server/drizzle/0039_mod_registry_versions_pin_failed_at.sql @@ -0,0 +1,8 @@ +-- Tracks a mod_registry_versions row that backfill-branch-pins.ts gave up on +-- permanently resolving to a commit-pinned downloadUrl (see that script and +-- mods-sync.service.ts's pinBranchVersionIfNew) after exhausting its +-- retries within a run. Null means "never permanently failed" -- either +-- already pinned (downloadUrl no longer classifies as 'branch') or not +-- attempted yet. Lets a re-run of the backfill skip known-dead rows by +-- default instead of re-spending GitHub API calls on them every time. +ALTER TABLE "mod_registry_versions" ADD COLUMN "pin_failed_at" timestamp with time zone; diff --git a/apps/server/drizzle/0040_mod_search_terms.sql b/apps/server/drizzle/0040_mod_search_terms.sql new file mode 100644 index 0000000..936aa40 --- /dev/null +++ b/apps/server/drizzle/0040_mod_search_terms.sql @@ -0,0 +1,6 @@ +-- Admin-owned alternative search terms per mod (e.g. "wimf" for "What's in +-- my Fool") -- same "never synced from the index" shape as hidden/featured/ +-- ranked_version, but editable alongside categories via the general +-- field-edit endpoint rather than its own dedicated route. See schema.ts's +-- own doc comment on mod_registry.search_terms. +ALTER TABLE "mod_registry" ADD COLUMN "search_terms" text[] DEFAULT '{}' NOT NULL; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index a655d3e..3363c3e 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -274,6 +274,20 @@ "when": 1791400000000, "tag": "0038_mod_registry_index_source", "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1791500000000, + "tag": "0039_mod_registry_versions_pin_failed_at", + "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1791600000000, + "tag": "0040_mod_search_terms", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 8e6c787..485bcf6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "migrate": "tsx src/infrastructure/db/migrate.ts", "generate": "drizzle-kit generate", "backfill-mod-hashes": "tsx src/features/mods/backfill-mod-hashes.ts", + "backfill-branch-pins": "tsx src/features/mods/backfill-branch-pins.ts", "test": "vitest run", "test:watch": "vitest", "test:e2e": "vitest run --config vitest.e2e.config.ts", diff --git a/apps/server/src/features/mods/backfill-branch-pins.ts b/apps/server/src/features/mods/backfill-branch-pins.ts new file mode 100644 index 0000000..6a4fc45 --- /dev/null +++ b/apps/server/src/features/mods/backfill-branch-pins.ts @@ -0,0 +1,67 @@ +/** + * One-off maintenance operation: pins every still-branch-tracked + * mod_registry_versions row (its downloadUrl still classifies as 'branch' -- + * see mod-source-classifier.ts) to a real, permanently-fetchable + * commit-specific downloadUrl, and re-downloads + re-hashes each one against + * that pinned URL -- see resolveCommitPinnedDownloadUrl's doc comment + * (custom-mod-version-check.service.ts) for why the URL is unfetchable-once- + * stale in the first place. The hash gets re-verified too, not just the URL: + * a stale row's stored sha256 was originally computed against whatever the + * branch's live tip happened to be at hash-time, not necessarily the exact + * commit its own version label names, so trusting the existing hash could + * silently leave it mismatched against the commit it's about to claim to be + * pinned to. + * + * Complements pinBranchVersionIfNew() in mods-sync.service.ts, which only + * ever pins a version the first time it's synced going forward -- this is + * the one-time catch-up pass for every row that was already synced (and + * hashed) before that fix existed. + * + * A row that fails to resolve/hash after a few retries within this run (a + * deleted repo/branch, a garbage-collected commit, or a rate limit that + * outlasts the retries) is marked via pinFailedAt and skipped on future + * runs, so a known-dead row doesn't keep burning GitHub API calls forever. + * Pass --retry-failed to also re-attempt rows an earlier run gave up on -- + * useful after whatever made them unresolvable might have changed (a + * renamed repo, an expired rate limit). + * + * Safe to re-run: a row that's already pinned (downloadUrl no longer + * classifies as 'branch') is left alone, and re-pinning an already-pinned + * row would just reproduce the same result anyway. + * + * Needs the same runtime as the server itself -- network access to every + * mod's GitHub download URL -- so run it inside the deployed container: + * + * docker compose exec api pnpm --filter balatro-multiplayer-api-server backfill-branch-pins + * docker compose exec api pnpm --filter balatro-multiplayer-api-server backfill-branch-pins --retry-failed + * + * or locally against a real DATABASE_URL: + * + * tsx --env-file=.env src/features/mods/backfill-branch-pins.ts + */ + +import { pool } from '../../infrastructure/db/index.js' +import { runBranchPinBackfill } from './mods-sync.service.js' + +const retryFailed = process.argv.includes('--retry-failed') + +runBranchPinBackfill({ retryFailed }) + .then(async (summary) => { + await pool.end() + console.log( + `[backfill-branch-pins] Done: ${summary.pinned} pinned, ${summary.alreadyPinned} already pinned, ${summary.failed} newly failed, ${summary.skippedFailed} skipped (already marked failed, pass --retry-failed to retry them).`, + ) + if (summary.failedRows.length > 0) { + console.log( + `[backfill-branch-pins] Rows marked failed this run: ${summary.failedRows + .map((r) => `${r.modId}@${r.version}`) + .join(', ')}`, + ) + } + process.exit(0) + }) + .catch(async (err) => { + console.error('[backfill-branch-pins] Failed:', err) + await pool.end().catch(() => {}) + process.exit(1) + }) diff --git a/apps/server/src/features/mods/custom-mod-version-check.service.ts b/apps/server/src/features/mods/custom-mod-version-check.service.ts index 1ceafee..304861a 100644 --- a/apps/server/src/features/mods/custom-mod-version-check.service.ts +++ b/apps/server/src/features/mods/custom-mod-version-check.service.ts @@ -6,7 +6,10 @@ import { classifyDownloadUrl } from './mod-source-classifier.js' // custom mods (mod_registry.isCustom rows) that opt into // automaticVersionCheck -- upstream mods get this for free from that same // script running on the real skyline69/balatro-mod-index repo, but a custom -// mod has no meta.json anywhere for it to have already run against. +// mod has no meta.json anywhere for it to have already run against. Also +// home to resolveCommitPinnedDownloadUrl() below, which both this module's +// own HEAD-tracking callers and mods-sync.service.ts's upstream-index sync +// share -- see that function's doc comment. export type VersionSource = 'latest_tag' | 'specific_tag' | 'head' export interface VersionCheckInput { @@ -107,6 +110,59 @@ async function fetchHeadSha( return data[0].sha.slice(0, 7) } +// A short (7-char, matching what update_mod_versions.py/fetchHeadSha above +// both write) or full (40-char) git commit SHA. +const GIT_SHA_LIKE = /^[0-9a-f]{7,40}$/i + +// Branch-tracked mods (no GitHub releases -- downloadUrl classifies as +// 'branch') get their `version` bumped by update_mod_versions.py to the +// *whole repo's* latest commit SHA on any commit anywhere in the repo, but +// that script only ever rewrites `downloadURL` for its tag/release cases -- +// never for the HEAD case (see that script: the `if`/`elif` guarding +// `meta['downloadURL'] = ...` has no branch for `VersionSource.HEAD` at +// all). So every version ever recorded for such a mod carries the exact +// same URL: the branch's own live-HEAD archive link. Downloading it always +// fetches "whatever's on the branch right now", never the specific commit +// the version label names -- confirmed live via +// skyline69/balatro-mod-index's Aikoyori@Aikoyoris-Shenanigans, whose +// mod_registry_versions history has a dozen distinct commit-hash version +// labels all sharing one identical downloadUrl and (whenever the branch +// hadn't actually moved between two of those label bumps) identical sha256. +// The real cost isn't the duplication itself -- it's that an *older* label +// becomes permanently unfetchable once the branch advances past it: nothing +// in this pipeline can ever again produce that label's original bytes, +// which silently breaks any profile (a Ranked rankedVersion pin, or a user +// manually pinning an older entry from the version dropdown) sitting on it. +// +// This resolves the label to a real, permanently-fetchable commit-pinned +// codeload URL instead -- one extra GitHub API call, made only the first +// time a given (modId, version) is about to be hashed and stored (see +// mods-sync.service.ts's pinBranchVersionIfNew()), never on every sync, +// since a version already hashed/stored is never re-resolved. Returns null +// (falls back to the literal branch URL -- exactly today's behavior) +// whenever resolution isn't possible: the URL isn't a branch-archive shape, +// the version string doesn't look like a git SHA at all (a custom mod's own +// hand-typed version string, say), or the GitHub lookup fails/rate-limits -- +// never a hard failure that should abort the sync over one mod. +export async function resolveCommitPinnedDownloadUrl( + downloadUrl: string, + version: string, +): Promise { + if (classifyDownloadUrl(downloadUrl) !== 'branch') return null + if (!GIT_SHA_LIKE.test(version)) return null + + const repoInfo = extractRepoInfo(downloadUrl) + if (!repoInfo) return null + const { owner, repo } = repoInfo + + const res = await githubGet(`/repos/${owner}/${repo}/commits/${version}`) + if (!res || res.status === 404) return null + const data = (await res.json()) as { sha?: string } + if (!data.sha) return null + + return `https://codeload.github.com/${owner}/${repo}/zip/${data.sha}` +} + async function fetchSpecificTag( owner: string, repo: string, diff --git a/apps/server/src/features/mods/mods-sync.service.ts b/apps/server/src/features/mods/mods-sync.service.ts index ea08178..79fa88a 100644 --- a/apps/server/src/features/mods/mods-sync.service.ts +++ b/apps/server/src/features/mods/mods-sync.service.ts @@ -4,21 +4,30 @@ import path from 'node:path' import AdmZip from 'adm-zip' import { env } from '../../env.js' import { + applyBranchPin, applyDetectedVersion, getStoredHash, listAllVersionsWithDownloadUrl, listCustomMods, listModIdsBySource, + listVersionsWithDownloadUrl, + markVersionPinFailed, pruneModsMissingFrom, storeComputedHash, upsertModFromIndex, upsertVersionRow, } from '../../infrastructure/gateways/mods.gateway.js' -import { checkCustomModVersion } from './custom-mod-version-check.service.js' +import { + checkCustomModVersion, + resolveCommitPinnedDownloadUrl, +} from './custom-mod-version-check.service.js' import { relocateModRoot } from './mod-archive-flatten.js' import { computeModFolderHash } from './mod-folder-hash.js' import { computeMergedIndex, type ThunderstoreOutcome } from './mod-index-merge.js' -import { resolveReliableDownloadUrl } from './mod-source-classifier.js' +import { + classifyDownloadUrl, + resolveReliableDownloadUrl, +} from './mod-source-classifier.js' import { fetchThunderstoreModIndex } from './thunderstore-mod-index.service.js' import { fetchUpstreamModIndex } from './upstream-mod-index.service.js' @@ -113,6 +122,27 @@ async function computeModFolderHashForRelease( } } +// Applies resolveCommitPinnedDownloadUrl() (see that function's doc comment +// in custom-mod-version-check.service.ts for the underlying problem) at the +// one point in this sync where it matters: right before a (modId, version) +// pair is about to be hashed and stored for the very first time. A version +// that's already been hashed is left alone unconditionally -- its +// downloadUrl (pinned or not) is already whatever was hashed for it, and +// re-resolving would just spend a GitHub API call to confirm what's already +// true. Falls back to returning downloadUrl unchanged whenever pinning +// isn't applicable or the GitHub lookup fails -- never blocks the sync. +async function pinBranchVersionIfNew( + modId: string, + version: string, + downloadUrl: string, +): Promise { + const alreadyHashed = await getStoredHash(modId, version) + if (alreadyHashed) return downloadUrl + + const pinned = await resolveCommitPinnedDownloadUrl(downloadUrl, version) + return pinned ?? downloadUrl +} + interface HashCandidate { modId: string version: string @@ -363,6 +393,25 @@ async function runSync(): Promise { const hashCandidates: HashCandidate[] = [] for (const { entry, source } of merged.toUpsert) { + if (entry.latestVersion && entry.latestDownloadUrl) { + entry.latestDownloadUrl = await pinBranchVersionIfNew( + entry.id, + entry.latestVersion, + entry.latestDownloadUrl, + ) + // entries[].versions is this same (version, downloadUrl) pair + // wrapped for mod_registry_versions -- see + // upstream-mod-index.service.ts's buildEntry(). Keep it in sync + // with the pin above so the stored version row and + // mod_registry.latestDownloadUrl never disagree. + if (entry.versions[0]?.version === entry.latestVersion) { + entry.versions[0] = { + ...entry.versions[0], + downloadUrl: entry.latestDownloadUrl, + } + } + } + await upsertModFromIndex(entry, source) if (entry.latestVersion && entry.latestDownloadUrl) { @@ -390,12 +439,33 @@ async function runSync(): Promise { fixedReleaseTagUpdates: mod.fixedReleaseTagUpdates, }) if (detected) { + // detected.newDownloadUrl is null for the HEAD case (see + // checkCustomModVersion's own doc comment) -- that's exactly + // the branch-tracked shape pinBranchVersionIfNew() exists + // for, so resolve against whatever URL is actually in effect + // (the freshly detected one, or the mod's existing one) and + // only pass a non-null downloadUrl through to + // applyDetectedVersion when pinning actually produced one. + const effectiveDownloadUrl = + detected.newDownloadUrl ?? mod.latestDownloadUrl + let downloadUrlToApply = detected.newDownloadUrl + if (effectiveDownloadUrl) { + const pinned = await pinBranchVersionIfNew( + mod.id, + detected.newVersion, + effectiveDownloadUrl, + ) + if (pinned !== effectiveDownloadUrl) { + downloadUrlToApply = pinned + } + } + await applyDetectedVersion(mod.id, { version: detected.newVersion, - downloadUrl: detected.newDownloadUrl, + downloadUrl: downloadUrlToApply, }) latestVersion = detected.newVersion - latestDownloadUrl = detected.newDownloadUrl ?? mod.latestDownloadUrl + latestDownloadUrl = downloadUrlToApply ?? mod.latestDownloadUrl versionsChecked++ } } @@ -460,3 +530,126 @@ export function syncModRegistry(): Promise { } return inFlight } + +// --- One-off backfill: pin every pre-existing branch-tracked version row +// (see backfill-branch-pins.ts) --- +// +// pinBranchVersionIfNew() above only ever pins a version the first time +// it's synced -- every mod_registry_versions row written before that fix +// existed is still sitting on its original moving-branch-tip URL (and a +// sha256 computed against whatever that tip happened to be at hash-time, +// not necessarily the exact commit its own version label names). This is +// the one-time catch-up pass for that backlog, run manually via +// `pnpm backfill-branch-pins`, not part of the regular hourly/startup sync. + +const PIN_RETRY_ATTEMPTS = 3 +const PIN_RETRY_DELAY_MS = 2000 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// One full resolve-then-hash cycle for a single stale row, retried up to +// PIN_RETRY_ATTEMPTS times with a short linear backoff. A transient GitHub +// rate-limit/5xx is already swallowed to null by +// resolveCommitPinnedDownloadUrl()'s own best-effort design -- without a +// retry here, a single blip would look identical to a genuinely dead +// repo/commit. The regular hourly sync gets this same self-healing for free +// ("try again next hour"); a one-off backfill run has no next hour to fall +// back on, hence its own tighter retry loop. +async function attemptBranchPin( + modId: string, + version: string, + downloadUrl: string, +): Promise<{ downloadUrl: string; sha256: string } | null> { + for (let attempt = 1; attempt <= PIN_RETRY_ATTEMPTS; attempt++) { + const pinnedUrl = await resolveCommitPinnedDownloadUrl(downloadUrl, version) + if (pinnedUrl) { + const hash = await computeModFolderHashForRelease( + modId, + version, + pinnedUrl, + ) + if (hash) return { downloadUrl: pinnedUrl, sha256: hash } + } + if (attempt < PIN_RETRY_ATTEMPTS) { + await sleep(PIN_RETRY_DELAY_MS * attempt) + } + } + return null +} + +export interface BranchPinBackfillOptions { + // Also re-attempts rows already marked pinFailedAt by an earlier run, + // instead of skipping them (the default) -- use after fixing whatever + // made them unresolvable (a renamed repo, an expired rate limit that + // outlasted this script's own retries, etc.). + retryFailed?: boolean +} + +export interface BranchPinBackfillSummary { + pinned: number + alreadyPinned: number + failed: number + skippedFailed: number + failedRows: Array<{ modId: string; version: string }> +} + +export async function runBranchPinBackfill( + options: BranchPinBackfillOptions = {}, +): Promise { + const rows = await listVersionsWithDownloadUrl() + const targets = rows.filter( + (r) => classifyDownloadUrl(r.downloadUrl) === 'branch', + ) + + const summary: BranchPinBackfillSummary = { + pinned: 0, + alreadyPinned: rows.length - targets.length, + failed: 0, + skippedFailed: 0, + failedRows: [], + } + + // Same bounded-worker-pool shape as runHashPool above (HASH_CONCURRENCY + // wide) -- each row here does real work too (a resolve call, then a full + // download+extract+hash), so unbounded concurrency has the same + // GitHub-connection-reset risk flagged on recomputeAllModHashes. + let next = 0 + async function worker(): Promise { + while (true) { + const i = next++ + if (i >= targets.length) return + const row = targets[i] + + if (row.pinFailedAt && !options.retryFailed) { + summary.skippedFailed++ + continue + } + + const result = await attemptBranchPin( + row.modId, + row.version, + row.downloadUrl, + ) + if (!result) { + await markVersionPinFailed(row.modId, row.version) + summary.failed++ + summary.failedRows.push({ modId: row.modId, version: row.version }) + console.warn( + `[backfill-branch-pins] Couldn't pin ${row.modId}@${row.version} after ${PIN_RETRY_ATTEMPTS} attempts - marked pinFailedAt.`, + ) + continue + } + + await applyBranchPin(row.modId, row.version, result.downloadUrl, result.sha256) + summary.pinned++ + console.log( + `[backfill-branch-pins] Pinned ${row.modId}@${row.version} -> ${result.downloadUrl}`, + ) + } + } + + await Promise.all(Array.from({ length: HASH_CONCURRENCY }, () => worker())) + return summary +} diff --git a/apps/server/src/features/webadmin/mods.route.ts b/apps/server/src/features/webadmin/mods.route.ts index 267d5c7..67c2952 100644 --- a/apps/server/src/features/webadmin/mods.route.ts +++ b/apps/server/src/features/webadmin/mods.route.ts @@ -282,6 +282,15 @@ router.patch('/mods/:modId', async (req, res, next) => { } input.categories = body.categories as string[] } + if (body.searchTerms !== undefined) { + if ( + !Array.isArray(body.searchTerms) || + !body.searchTerms.every((t) => typeof t === 'string') + ) { + throw new AppError('searchTerms must be a string array', 400) + } + input.searchTerms = body.searchTerms as string[] + } if (body.requiresSteamodded !== undefined) { if (typeof body.requiresSteamodded !== 'boolean') throw new AppError('requiresSteamodded must be a boolean', 400) @@ -417,6 +426,9 @@ router.post('/mods', async (req, res, next) => { categories: Array.isArray(body.categories) ? (body.categories as string[]) : undefined, + searchTerms: Array.isArray(body.searchTerms) + ? (body.searchTerms as string[]) + : undefined, requiresSteamodded: typeof body.requiresSteamodded === 'boolean' ? body.requiresSteamodded @@ -492,6 +504,9 @@ router.put('/mods/:modId/custom', async (req, res, next) => { categories: Array.isArray(body.categories) ? (body.categories as string[]) : undefined, + searchTerms: Array.isArray(body.searchTerms) + ? (body.searchTerms as string[]) + : undefined, requiresSteamodded: bool('requiresSteamodded'), requiresTalisman: bool('requiresTalisman'), repoUrl: strOrNull('repoUrl'), diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts index 79585bb..fced2a3 100644 --- a/apps/server/src/infrastructure/db/schema.ts +++ b/apps/server/src/infrastructure/db/schema.ts @@ -682,6 +682,18 @@ export const modRegistry = pgTable('mod_registry', { title: varchar('title', { length: 128 }).notNull(), author: varchar('author', { length: 128 }).notNull(), categories: text('categories').array().notNull().default(sql`'{}'::text[]`), + // Admin-owned aliases a mod is commonly known/searched by but that don't + // appear in its title -- e.g. "wimf" for "What's in my Fool". Unlike + // categories, this has no upstream-index counterpart at all (the base + // index carries no such concept), so it's never touched by + // upsertModFromIndex/SYNCABLE_MOD_FIELDS and never participates in + // overriddenFields -- same "permanently admin-owned" shape as featured/ + // hidden/rankedVersion above, just editable through the general PATCH + // .../mods/:modId field-edit endpoint alongside categories rather than + // its own dedicated PUT (see updateModFields()'s own comment). Matched + // case-insensitively as a substring, same as title, by whatever reads + // this for search (currently /admin/ranked-mods' filter box). + searchTerms: text('search_terms').array().notNull().default(sql`'{}'::text[]`), requiresSteamodded: boolean('requires_steamodded').notNull().default(true), requiresTalisman: boolean('requires_talisman').notNull().default(false), repoUrl: text('repo_url'), @@ -773,6 +785,18 @@ export const modRegistryVersions = pgTable( sha256: varchar('sha256', { length: 64 }), downloadUrl: text('download_url'), releasedAt: timestamp('released_at', { withTimezone: true }), + // Set once backfill-branch-pins.ts gives up on permanently resolving + // this row's downloadUrl to a commit-pinned one (see that script and + // mods-sync.service.ts's pinBranchVersionIfNew) after exhausting its + // retries within a run -- a genuinely dead repo/branch/commit, not a + // transient rate-limit. Null means "never permanently failed" (either + // already pinned -- downloadUrl no longer classifies as 'branch' -- + // or not attempted yet). A later re-run of the backfill script skips + // rows where this is set unless told to retry them, so a known-dead + // row doesn't keep burning GitHub API calls on every run; an admin + // can still force a retry (see that script's --retry-failed flag) if + // something later becomes resolvable again (e.g. a renamed repo). + pinFailedAt: timestamp('pin_failed_at', { withTimezone: true }), }, (t) => [ uniqueIndex('mod_registry_versions_mod_version_idx').on(t.modId, t.version), diff --git a/apps/server/src/infrastructure/gateways/mods.gateway.ts b/apps/server/src/infrastructure/gateways/mods.gateway.ts index 9e7d2a1..e3d1211 100644 --- a/apps/server/src/infrastructure/gateways/mods.gateway.ts +++ b/apps/server/src/infrastructure/gateways/mods.gateway.ts @@ -30,6 +30,11 @@ export async function listPublicMods(opts?: { includeHidden?: boolean }) { thumbnailUrl: modRegistry.thumbnailUrl, isCustom: modRegistry.isCustom, overriddenFields: modRegistry.overriddenFields, + // Included here (not just on the single-mod detail fetch) so + // /admin/ranked-mods' search box can filter the already-loaded list + // client-side without a second round trip per keystroke - see + // page.tsx's search filtering. + searchTerms: modRegistry.searchTerms, }) .from(modRegistry) .where(opts?.includeHidden ? undefined : eq(modRegistry.hidden, false)) @@ -473,6 +478,85 @@ export async function storeComputedHash( ) } +// --- Branch-tracked version pinning backfill (backfill-branch-pins.ts) --- + +export interface BranchPinCandidate { + modId: string + version: string + downloadUrl: string + pinFailedAt: Date | null +} + +// Every mod_registry_versions row that has a downloadUrl -- narrowing down +// to the ones still pointing at a live branch archive (not yet pinned to a +// specific commit) is backfill-branch-pins.ts's own job, via +// mod-source-classifier.ts's classifyDownloadUrl -- same "fetch broad, +// filter/branch in TS" shape as listAllVersionsWithDownloadUrl above. +export async function listVersionsWithDownloadUrl(): Promise< + BranchPinCandidate[] +> { + const rows = await db + .select({ + modId: modRegistryVersions.modId, + version: modRegistryVersions.version, + downloadUrl: modRegistryVersions.downloadUrl, + pinFailedAt: modRegistryVersions.pinFailedAt, + }) + .from(modRegistryVersions) + .where(isNotNull(modRegistryVersions.downloadUrl)) + + return rows.filter( + (r): r is BranchPinCandidate => r.downloadUrl !== null, + ) +} + +// Writes a successfully-pinned commit-specific downloadUrl and its +// freshly-recomputed hash onto a version row, and clears any earlier +// pinFailedAt -- a retried row that succeeds this time is no longer +// permanently failed. Mirrors sha256 onto mod_registry.latestSha256 the +// same way storeComputedHash does, for the same reason (this version can +// still be the mod's current latest). +export async function applyBranchPin( + modId: string, + version: string, + downloadUrl: string, + sha256: string, +): Promise { + await db + .update(modRegistryVersions) + .set({ downloadUrl, sha256, pinFailedAt: null }) + .where( + and( + eq(modRegistryVersions.modId, modId), + eq(modRegistryVersions.version, version), + ), + ) + await db + .update(modRegistry) + .set({ latestSha256: sha256 }) + .where( + and(eq(modRegistry.id, modId), eq(modRegistry.latestVersion, version)), + ) +} + +// Marks a row as permanently unpinnable after backfill-branch-pins.ts +// exhausts its retries for it within one run -- see +// mod_registry_versions.pinFailedAt's own doc comment in schema.ts. +export async function markVersionPinFailed( + modId: string, + version: string, +): Promise { + await db + .update(modRegistryVersions) + .set({ pinFailedAt: new Date() }) + .where( + and( + eq(modRegistryVersions.modId, modId), + eq(modRegistryVersions.version, version), + ), + ) +} + // --- Admin: ranked version (PUT/DELETE /api/webadmin/mods/:modId(/ranked)) --- // The sole ranked-eligibility write path: null un-ranks the mod, any other @@ -532,6 +616,7 @@ export interface CustomModInput { title: string author: string categories?: string[] + searchTerms?: string[] requiresSteamodded?: boolean requiresTalisman?: boolean repoUrl?: string | null @@ -561,6 +646,7 @@ export async function createCustomMod( title: input.title, author: input.author, categories: input.categories ?? [], + searchTerms: input.searchTerms ?? [], requiresSteamodded: input.requiresSteamodded ?? true, requiresTalisman: input.requiresTalisman ?? false, repoUrl: input.repoUrl ?? null, @@ -594,6 +680,7 @@ export interface UpdateCustomModInput { title?: string author?: string categories?: string[] + searchTerms?: string[] requiresSteamodded?: boolean requiresTalisman?: boolean repoUrl?: string | null @@ -673,6 +760,12 @@ export async function updateModFields( touch('latestVersion', input.latestVersion) touch('latestDownloadUrl', input.latestDownloadUrl) + // Deliberately bypasses touch()/overriddenFields -- searchTerms has no + // upstream value to protect from a future sync (see schema.ts's own + // doc comment on mod_registry.searchTerms), so unlike every field + // above it's just written directly, on custom and synced mods alike. + if (input.searchTerms !== undefined) set.searchTerms = input.searchTerms + if (!existing.isCustom && edited.length > 0) { set.overriddenFields = [ ...new Set([...existing.overriddenFields, ...edited]), diff --git a/apps/server/src/tests/mods/custom-mod-version-check.test.ts b/apps/server/src/tests/mods/custom-mod-version-check.test.ts index 3304a0c..ab9fe28 100644 --- a/apps/server/src/tests/mods/custom-mod-version-check.test.ts +++ b/apps/server/src/tests/mods/custom-mod-version-check.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AppError } from '../../shared/utils/errors.js' import { checkCustomModVersion, + resolveCommitPinnedDownloadUrl, resolveSourceInput, } from '../../features/mods/custom-mod-version-check.service.js' @@ -184,6 +185,86 @@ describe('checkCustomModVersion', () => { }) }) +describe('resolveCommitPinnedDownloadUrl', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('resolves a branch-archive URL + short-SHA version to a commit-pinned codeload URL', async () => { + mockFetch((url) => { + if (url.endsWith('/repos/Aikoyori/Balatro-Aikoyoris-Shenanigans/commits/55be56c')) { + return jsonResponse(200, { + sha: '55be56c1234567890abcdef1234567890abcdef', + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Aikoyori/Balatro-Aikoyoris-Shenanigans/archive/refs/heads/stable.zip', + '55be56c', + ) + + expect(result).toBe( + 'https://codeload.github.com/Aikoyori/Balatro-Aikoyoris-Shenanigans/zip/55be56c1234567890abcdef1234567890abcdef', + ) + }) + + it('returns null for a non-branch (release/custom) URL - nothing to pin, the literal URL is already stable', async () => { + mockFetch(() => { + throw new Error('should not fetch') + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/releases/download/v1.0.0/mod.zip', + 'v1.0.0', + ) + + expect(result).toBeNull() + }) + + it("returns null when the version doesn't look like a git SHA (e.g. a custom mod's own version string)", async () => { + mockFetch(() => { + throw new Error('should not fetch') + }) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + 'v1.0.0-beta', + ) + + expect(result).toBeNull() + }) + + it('returns null (not throw) when GitHub 404s on the commit lookup', async () => { + mockFetch(() => jsonResponse(404, {})) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + '0000000', + ) + + expect(result).toBeNull() + }) + + it('returns null (not throw) when GitHub responds rate-limited', async () => { + mockFetch( + () => + new Response('rate limit exceeded', { + status: 403, + headers: { 'x-ratelimit-remaining': '0' }, + }), + ) + + const result = await resolveCommitPinnedDownloadUrl( + 'https://github.com/Alice/Mod/archive/refs/heads/main.zip', + 'abcdef1', + ) + + expect(result).toBeNull() + }) +}) + describe('resolveSourceInput', () => { afterEach(() => { vi.unstubAllGlobals() diff --git a/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx b/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx index 4101dd8..4baba9c 100644 --- a/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx +++ b/apps/web/src/app/(home)/admin/ranked-mods/components/mod-form-dialog.tsx @@ -127,6 +127,25 @@ export function ModFormDialog({ } /> +
+ + + onFormChange({ ...form, searchTerms: e.target.value }) + } + /> +

+ Aliases players actually search by that don't appear in the + title - e.g. "wimf" for "What's in my Fool". Matched alongside + the title/id in the catalog search box above; never synced from + or overwritten by the upstream index. +

+
)} - + + setModSearch(e.target.value)} + placeholder='Search by name, id, or alternative search term (e.g. wimf)…' + className='max-w-sm' + /> {modsLoading || !mods ? (

Loading…

) : ( 0 + ? `No mods match "${modSearch.trim()}"` + : undefined + } onSetRankedVersion={(mod, version) => setRankedVersionMut.mutate({ modId: mod.id,