refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gamePlatformSupport,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte, type SQL } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
const MAX_OFFSET = 10000
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing", detail: { tags: ["Games"] } }).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
||||
const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100)
|
||||
const search = query.search || ""
|
||||
const genre = query.genre || ""
|
||||
const device = query.device || ""
|
||||
const sort = query.sort || "recent"
|
||||
const order = query.order === "asc" ? asc : desc
|
||||
|
||||
// ── New filter parameters ─────────────────────────────────────
|
||||
const minFps = query.minFps
|
||||
const maxFps = query.maxFps
|
||||
const fsrSupport = query.fsrSupport
|
||||
const protonNative = query.protonNative
|
||||
const antiCheatStatus = query.antiCheatStatus
|
||||
const playabilityStatus = query.playabilityStatus
|
||||
const steamReviewScore = query.steamReviewScore
|
||||
const isFree = query.isFree
|
||||
const hasMultiplayer = query.hasMultiplayer
|
||||
|
||||
// Build where conditions
|
||||
const conditions = []
|
||||
|
||||
// Search filter (title, developer, publisher)
|
||||
if (search) {
|
||||
const titleTerm = fuzzySearchTerm(search)
|
||||
const term = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
// Genre filter (supports comma-separated list with OR logic)
|
||||
if (genre) {
|
||||
const genres = genre.split(",").filter(Boolean)
|
||||
if (genres.length === 1) {
|
||||
conditions.push(sql`${games.genres} @> ${JSON.stringify([genres[0]])}::jsonb`)
|
||||
} else if (genres.length > 1) {
|
||||
// OR logic: game matches ANY of the selected genres
|
||||
const genreConditions = genres.map(
|
||||
(g) => sql`${games.genres} @> ${JSON.stringify([g])}::jsonb`,
|
||||
)
|
||||
conditions.push(or(...genreConditions)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Device filter
|
||||
if (device) {
|
||||
// Get games with platform support
|
||||
const supportedIds = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.hardwareSlug, device),
|
||||
eq(gamePlatformSupport.isSupported, true),
|
||||
),
|
||||
)
|
||||
|
||||
// Also get games with performance entries for this hardware
|
||||
const gamesWithBenchmarks = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(eq(performanceEntries.hardwareSlug, device))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
// Combine both sets
|
||||
const deviceGameIds = new Set([
|
||||
...supportedIds.map((s) => s.gameId),
|
||||
...gamesWithBenchmarks.map((b) => b.gameId),
|
||||
])
|
||||
|
||||
if (deviceGameIds.size > 0) {
|
||||
conditions.push(inArray(games.id, Array.from(deviceGameIds)))
|
||||
} else {
|
||||
return { data: [], total: 0, limit, offset, genres: [], devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── FPS range filter (games with benchmarks in this range) ────
|
||||
if (minFps || maxFps) {
|
||||
const fpsConditions: (SQL | undefined)[] = [
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
||||
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
||||
// Scope to active device when device filter is set
|
||||
device ? eq(performanceEntries.hardwareSlug, device) : undefined,
|
||||
].filter((c): c is SQL => c !== undefined)
|
||||
|
||||
const fpsSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(...fpsConditions))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fpsSubquery}) AS fps_sub)`)
|
||||
}
|
||||
|
||||
// ── FSR support filter ────────────────────────────────────────
|
||||
if (fsrSupport === "true") {
|
||||
const fsrSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(performanceEntries.upscalerType, "fsr"),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fsrSubquery}) AS fsr_sub)`)
|
||||
}
|
||||
|
||||
// ── Proton / Native filter ────────────────────────────────────
|
||||
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
||||
const protonConditions: SQL[] = []
|
||||
|
||||
if (protonNative === "proton" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
||||
}
|
||||
if (protonNative === "native" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "native"))
|
||||
}
|
||||
|
||||
const protonSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(or(...protonConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${protonSubquery}) AS proton_sub)`)
|
||||
}
|
||||
|
||||
// ── Anti-cheat status filter ──────────────────────────────────
|
||||
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
||||
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
||||
const acConditions: SQL[] = [
|
||||
eq(gamePlatformSupport.antiCheatRelevant, true),
|
||||
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
||||
]
|
||||
|
||||
const acSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(and(...acConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${acSubquery}) AS ac_sub)`)
|
||||
}
|
||||
|
||||
// ── Playability status filter ─────────────────────────────────
|
||||
const validPlayStatuses = ["great", "playable", "needs_tweaks", "unplayable", "unknown"]
|
||||
if (playabilityStatus && validPlayStatuses.includes(playabilityStatus)) {
|
||||
conditions.push(eq(games.playabilityStatus, playabilityStatus as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown"))
|
||||
}
|
||||
|
||||
// ── Steam review score filter (minimum score) ─────────────────
|
||||
if (steamReviewScore) {
|
||||
conditions.push(gte(games.steamReviewScore, Number(steamReviewScore)))
|
||||
}
|
||||
|
||||
// ── Free-to-play filter ───────────────────────────────────────
|
||||
if (isFree === "true") {
|
||||
conditions.push(eq(games.isFree, true))
|
||||
}
|
||||
|
||||
// ── Has multiplayer filter ────────────────────────────────────
|
||||
if (hasMultiplayer === "true") {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(games.onlineMultiplayerStatus, "supported"),
|
||||
eq(games.onlineMultiplayerStatus, "unknown"),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Fetch all genres (for filter options)
|
||||
const genreRows = await db
|
||||
.select({ genres: games.genres })
|
||||
.from(games)
|
||||
.where(sql`${games.genres} IS NOT NULL`)
|
||||
|
||||
const genreSet = new Set<string>()
|
||||
for (const row of genreRows) {
|
||||
if (Array.isArray(row.genres)) {
|
||||
for (const g of row.genres) {
|
||||
if (typeof g === "string") genreSet.add(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all hardware devices (for filter options)
|
||||
const deviceRows = await db
|
||||
.select({ slug: hardware.slug, name: hardware.name })
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
// Count total games matching filters
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(games)
|
||||
.where(where)
|
||||
|
||||
// Determine sort order
|
||||
let orderBy
|
||||
let needsPostSort = false
|
||||
let postSortField: string | null = null
|
||||
|
||||
switch (sort) {
|
||||
case "name":
|
||||
orderBy = order(games.title)
|
||||
break
|
||||
case "popularity":
|
||||
orderBy = order(games.recommendationsTotal)
|
||||
break
|
||||
case "release_date":
|
||||
orderBy = order(games.releaseDate)
|
||||
break
|
||||
case "steam_reviews":
|
||||
orderBy = order(games.steamReviewScore)
|
||||
break
|
||||
case "performance":
|
||||
// Performance sort requires a subquery — use SQL ORDER BY directly
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) DESC NULLS LAST`
|
||||
if (query.order === "asc") {
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) ASC NULLS LAST`
|
||||
}
|
||||
break
|
||||
case "benchmarks":
|
||||
needsPostSort = true
|
||||
postSortField = "benchmarkCount"
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
case "recent":
|
||||
default:
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
}
|
||||
|
||||
// Fetch games page
|
||||
const data = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
developer: games.developer,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
genres: games.genres,
|
||||
source: games.source,
|
||||
createdAt: games.createdAt,
|
||||
isFree: games.isFree,
|
||||
releaseDate: games.releaseDate,
|
||||
steamReviewScore: games.steamReviewScore,
|
||||
recommendationsTotal: games.recommendationsTotal,
|
||||
playabilityStatus: games.playabilityStatus,
|
||||
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
||||
})
|
||||
.from(games)
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const gameIds = data.map((g) => g.id)
|
||||
|
||||
// Fetch benchmark counts for the returned games (separate query to avoid subquery ambiguity)
|
||||
const benchmarkCounts = gameIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
: []
|
||||
|
||||
const benchmarkMap = new Map<string, number>()
|
||||
for (const row of benchmarkCounts) {
|
||||
benchmarkMap.set(row.gameId, row.count)
|
||||
}
|
||||
|
||||
// Fetch platform support for the returned games (prioritise Steam Deck)
|
||||
const platformMap = new Map<string, string>()
|
||||
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: string | null }>()
|
||||
if (gameIds.length > 0) {
|
||||
const platformRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
for (const row of platformRows) {
|
||||
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
|
||||
const existing = platformMap.get(row.gameId)
|
||||
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
|
||||
platformMap.set(row.gameId, row.protonStatus)
|
||||
}
|
||||
|
||||
const existingAc = antiCheatMap.get(row.gameId)
|
||||
if (row.antiCheatRelevant) {
|
||||
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
|
||||
antiCheatMap.set(row.gameId, {
|
||||
antiCheatRelevant: row.antiCheatRelevant,
|
||||
antiCheatStatus: row.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Performance stats: best FPS, raw performer, poor performance ──
|
||||
const rawPerformerMap = new Map<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
const batteryMinMap = new Map<string, number>()
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, and(
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
for (const row of perfStats) {
|
||||
bestFpsMap.set(row.gameId, row.bestFps)
|
||||
rawPerformerMap.set(row.gameId, row.isRawPerformer)
|
||||
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
|
||||
}
|
||||
|
||||
// Battery estimate for handheld devices
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND(
|
||||
(${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
|
||||
)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
sql`${hardware.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.fpsAvg))
|
||||
|
||||
const seenGames = new Set<string>()
|
||||
for (const row of batteryStats) {
|
||||
if (!seenGames.has(row.gameId)) {
|
||||
seenGames.add(row.gameId)
|
||||
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedData = data.map((g) => ({
|
||||
id: g.id,
|
||||
steamAppId: g.steamAppId,
|
||||
title: g.title,
|
||||
developer: g.developer,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
isFree: g.isFree,
|
||||
releaseDate: g.releaseDate,
|
||||
steamReviewScore: g.steamReviewScore,
|
||||
recommendationsTotal: g.recommendationsTotal,
|
||||
playabilityStatus: g.playabilityStatus,
|
||||
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
|
||||
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
|
||||
deckStatus: platformMap.get(g.id) ?? null,
|
||||
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
|
||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
|
||||
}))
|
||||
|
||||
// If sorting by benchmarks, re-sort the enriched data
|
||||
if (needsPostSort && postSortField === "benchmarkCount") {
|
||||
const sortFn = query.order === "asc"
|
||||
? (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => a.benchmarkCount - b.benchmarkCount
|
||||
: (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => b.benchmarkCount - a.benchmarkCount
|
||||
enrichedData.sort(sortFn)
|
||||
}
|
||||
|
||||
const [{ count: total }] = await countQuery
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
genres: Array.from(genreSet).sort(),
|
||||
devices: deviceRows,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
offset: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
genre: t.Optional(t.String()),
|
||||
device: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
order: t.Optional(t.String()),
|
||||
// New filter parameters
|
||||
minFps: t.Optional(t.String()),
|
||||
maxFps: t.Optional(t.String()),
|
||||
fsrSupport: t.Optional(t.String()),
|
||||
protonNative: t.Optional(t.String()),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
steamReviewScore: t.Optional(t.String()),
|
||||
isFree: t.Optional(t.String()),
|
||||
hasMultiplayer: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user