From affdcb85e34aebbdf9d355dbcad96f55376a33b0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 20 Oct 2026 21:03:54 -0400 Subject: [PATCH] [rooms] fix slow engagement counters --- .../0031_room_engagement_counters.sql | 27 ++ apps/rooms/src/openapi.ts | 11 +- apps/rooms/src/test/integration/api.test.ts | 17 +- packages/domain/src/rooms-db.ts | 282 ++++++++---------- 4 files changed, 167 insertions(+), 170 deletions(-) create mode 100644 apps/rooms/migrations/0031_room_engagement_counters.sql diff --git a/apps/rooms/migrations/0031_room_engagement_counters.sql b/apps/rooms/migrations/0031_room_engagement_counters.sql new file mode 100644 index 0000000..19452c1 --- /dev/null +++ b/apps/rooms/migrations/0031_room_engagement_counters.sql @@ -0,0 +1,27 @@ +-- Cheer and favorite counters on `room`, as columns — the cheer/favorite twins of +-- `visits` (0011). +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. +-- +-- Until now `Stats.CheerCount`/`FavoriteCount` were DERIVED on every read by aggregating +-- the per-player `interaction` rows (`SELECT room_id, SUM(cheered), SUM(favorited) … +-- GROUP BY room_id`). 0021's covering index made the per-room form cheap, but the browse +-- and discovery feeds rank EVERY listable room, so they ran the unfiltered form — a walk +-- of the whole index per call — and the discovery page runs the hot feed once per +-- category row. That was the bulk of the database's reads. +-- +-- Now each counter is kept current by the toggles themselves: the same batch that flips a +-- player's `interaction.cheered`/`favorited` applies a `cheers = cheers ± 1` to the room, +-- so a cheer still shows immediately and concurrent toggles can't lose each other. The +-- per-player rows stay the record of WHO cheered; the columns are the count of them. +-- +-- The backfill below is the one remaining full aggregate, run once here. `room_id` is a +-- virtual generated column, so the correlated subquery resolves it per row; the sums come +-- off `idx_interaction_room` without touching the table. + +ALTER TABLE room ADD COLUMN cheers INTEGER NOT NULL DEFAULT 0; +ALTER TABLE room ADD COLUMN favorites INTEGER NOT NULL DEFAULT 0; + +UPDATE room SET + cheers = (SELECT COALESCE(SUM(cheered), 0) FROM interaction WHERE interaction.room_id = room.room_id), + favorites = (SELECT COALESCE(SUM(favorited), 0) FROM interaction WHERE interaction.room_id = room.room_id); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index ac47b2d..81db642 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -149,11 +149,12 @@ export const RoomTagDto = z.object({ }) /** - * A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the - * per-player `interaction` rows on every read. `VisitCount` is the room's lifetime - * visits — the `room.visits` column, bumped by the `match` worker on every successful - * matchmake into the room. Nothing records distinct visitors, so `VisitorCount` stays - * at 0. + * A room's engagement counters. `CheerCount`/`FavoriteCount` are the `room.cheers`/ + * `favorites` columns, moved by the cheer/favorite toggles as they flip the per-player + * `interaction` row (so a count is how many players currently have the flag set). + * `VisitCount` is the room's lifetime visits — the `room.visits` column, bumped by the + * `match` worker on every successful matchmake into the room. Nothing records distinct + * visitors, so `VisitorCount` stays at 0. */ export const RoomStatsDto = z.object({ CheerCount: z.int(), diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index deee9cf..8deaf6f 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -4769,7 +4769,7 @@ describe('rooms endpoints', () => { expect(otherGet).toMatchObject({ Cheered: false, Favorited: false }) }) - it('room Stats aggregate cheers/favorites from the interaction table', async () => { + it('room Stats count cheers/favorites on the room row, kept by the toggles', async () => { type Stats = { CheerCount: number FavoriteCount: number @@ -4818,6 +4818,21 @@ describe('rooms endpoints', () => { VisitCount: 0, }) + // The counts are columns on the room, not an aggregate taken on read — the toggles + // keep them. A repeated clear changes nothing (the flag was already off), and neither + // does clearing a flag a player never set, so neither counts down again. + await interact('562', 'cheer', 'DELETE') + await interact('563', 'favorite', 'DELETE') + expect(await direct()).toMatchObject({ CheerCount: 1, FavoriteCount: 1 }) + expect( + await env.DB.prepare('SELECT cheers, favorites FROM room WHERE room_id = 15').first() + ).toEqual({ cheers: 1, favorites: 1 }) + // Toggling off and on again lands back where it started. + await interact('561', 'cheer', 'PUT') + expect(await direct()).toMatchObject({ CheerCount: 0 }) + await interact('561', 'cheer', 'PUT') + expect(await direct()).toMatchObject({ CheerCount: 1 }) + // VisitCount is the `room.visits` column (what match bumps on each matchmake), // served on every read of the room — here and in the search results — and it // survives the cheer/favorite aggregation rather than being zeroed by it. diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index de627ab..fb034b9 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -17,7 +17,7 @@ * database read-only and uses `getRoomById`. Each imports the subset it needs. */ -import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds' +import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds' import { Accessibility, Role } from './enums' import { countPlayersByRoom } from './presence-db' import { resyncInstanceCapacity } from './room-instance-db' @@ -41,6 +41,15 @@ export const ROOM_SCHEMA_DDL: string[] = [ -- in the blob so a visit is one atomic UPDATE that can't lose a concurrent -- read-modify-write of the whole room. visits INTEGER NOT NULL DEFAULT 0, + -- Cheer and favorite counters (migrations/0031_room_engagement_counters.sql, which + -- appends them here): how many players currently have \`interaction.cheered\` / + -- \`favorited\` set on the room, served as \`Stats.CheerCount\`/\`FavoriteCount\`. + -- Maintained by the toggles ({@link toggleCheer} and friends) in the same batch as the + -- per-player row, as a \`cheers = cheers ± 1\` UPDATE like \`visits\`. They used to be + -- aggregated from \`interaction\` on every read, and the feeds — which rank every + -- listable room — aggregated the WHOLE table per call; see parseRow. + cheers INTEGER NOT NULL DEFAULT 0, + favorites INTEGER NOT NULL DEFAULT 0, -- The two flags every public feed filters on, alongside \`is_dorm\` -- (migrations/0014_room_listable.sql, which appends them here). Generated like the -- rest so the blob stays the only copy; they exist to be INDEXED — see @@ -111,9 +120,10 @@ export const ROOM_SCHEMA_DDL: string[] = [ PRIMARY KEY (player_id, room_id) )`, // Per-ROOM access (migrations/0021_interaction_room_index.sql): the primary key - // serves the per-player reads, but the cheer/favorite aggregate every room read runs - // (`getRoomStats`) filters by room_id, which without this scanned the whole table. - // Covering — both counters are in the index, so the aggregate never touches the table. + // serves the per-player reads; this serves deleting a room's rows ({@link deleteRoom}) + // and the one-off backfill of `room.cheers`/`favorites` (migration 0031). It was + // added for a per-read cheer/favorite aggregate that no longer exists — the counters + // are columns on `room` now — and covers the two flags from that era. `CREATE INDEX IF NOT EXISTS idx_interaction_room ON interaction (room_id, cheered, favorited)`, // Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player), // so re-banning someone already banned updates their row rather than appending. @@ -2215,14 +2225,20 @@ export async function moveSubRoom( interface RoomRow { data: string visits: number + cheers: number + favorites: number } /** - * The columns every room read selects. `visits` is authoritative for the room's - * `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to - * come back with the blob on every read; a join aliases them (`r.data AS data`). + * The columns every room read selects. `visits`, `cheers` and `favorites` are + * authoritative for the room's `Stats.VisitCount`/`CheerCount`/`FavoriteCount` (the blob + * keeps them at 0 — see {@link storedStats}), so they have to come back with the blob on + * every read; a join aliases them (`r.data AS data`), see {@link COUNTER_COLUMNS}. */ -const ROOM_COLUMNS = 'data, visits' +const ROOM_COLUMNS = 'data, visits, cheers, favorites' + +/** The counter columns of {@link ROOM_COLUMNS} as a join selects them, aliased to the row's names. */ +const COUNTER_COLUMNS = 'r.visits AS visits, r.cheers AS cheers, r.favorites AS favorites' /** * The rooms a public feed may consider, as a SQL predicate: public, not a dorm, and not @@ -2287,7 +2303,7 @@ const RANK_COLUMNS = `json_object(${[ 'Stats', ] .map((key) => `'${key}', data -> '$.${key}'`) - .join(', ')}) AS data, visits` + .join(', ')}) AS data, visits, cheers, favorites` /** * The full rooms behind a page of {@link RANK_COLUMNS} stubs, in the stubs' order, parsed @@ -2330,11 +2346,17 @@ function attachRoomDtoDefaults(room: Room): void { /** * Parse a room row: the stored blob with the counters the columns own folded back in. - * `visits` is a real column, so a room read straight from the DB carries the live count. + * `visits`/`cheers`/`favorites` are real columns, so a room read straight from the DB — + * a full read or a ranking stub alike — carries the live counts; nothing is derived. */ const parseRow = (row: RoomRow): Room => { const room = JSON.parse(row.data) as Room - room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 } + room.Stats = { + ...storedStats(room.Stats), + VisitCount: row.visits ?? 0, + CheerCount: row.cheers ?? 0, + FavoriteCount: row.favorites ?? 0, + } attachRoomDtoDefaults(room) return room } @@ -2419,9 +2441,9 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => { /** * Serialize a room for a full-blob write, dropping the parts that belong to another table * so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job), - * `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement + * `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the engagement * counters, which are zeroed rather than dropped so the key stays present (the - * `interaction` table's job — see {@link attachStats}). + * `visits`/`cheers`/`favorites` columns' job — see {@link parseRow}). * * Because `Tags` is dropped here, a write that means to CHANGE a room's tags has to write * the table itself; passing a room with a new `Tags` array through this silently discards @@ -2693,8 +2715,9 @@ function roomsByTagsQuery( return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i} ON f${i}.room_id = r.room_id` }) - // `data`/`visits` — in {@link ROOM_COLUMNS} or the ranking stub {@link RANK_COLUMNS} — - // are unqualified but unambiguous: the joined subqueries expose only `room_id`. + // `data` and the counters — in {@link ROOM_COLUMNS} or the ranking stub + // {@link RANK_COLUMNS} — are unqualified but unambiguous: the joined subqueries expose + // only `room_id`. return { sql: `SELECT ${columns} FROM room r ${joins.join(' ')}${filter}`, binds } } @@ -2732,31 +2755,16 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise { } // ---- Room stats ----------------------------------------------------------- -// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than -// stored: they're recomputed on every read, so a cheer shows up immediately and the -// counts can't drift from the per-player rows they're made of. The blob keeps them at 0 -// (see {@link serializeRoom}). -// -// `VisitCount` is neither stored in the blob nor derived: it's the `room.visits` column, -// incremented by {@link recordRoomVisit} on each matchmake and read back with the blob -// (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no -// per-player row to count — and it can't live in the blob, where a read-modify-write of -// the whole room would drop concurrent visits. `VisitorCount` (distinct visitors) is -// still left as the blob has it: `interaction.last_visited_at` is stamped per player by -// {@link recordRoomVisit}, but not for dorms, so counting those rows would under-report -// a dorm's visitors. - -/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */ -export interface RoomStats { - CheerCount: number - FavoriteCount: number -} - -interface RoomStatsRow { - room_id: number - cheers: number - favorites: number -} +// A room's engagement counters are COLUMNS on `room` — `visits`, `cheers`, `favorites` — +// read back with the blob on every read ({@link parseRow}) and never stored in the blob +// (see {@link serializeRoom}). Each is bumped by an atomic `col = col ± 1` UPDATE: visits +// by {@link recordRoomVisit} on each matchmake, cheers/favorites by the toggles below in +// the same batch as the per-player `interaction` row they mirror. A read-modify-write of +// the whole room would drop concurrent updates, and deriving the counts by aggregating +// `interaction` on every read (as this once did) cost a scan of the whole table per feed +// call. `VisitorCount` (distinct visitors) is still left as the blob has it: +// `interaction.last_visited_at` is stamped per player by {@link recordRoomVisit}, but not +// for dorms, so counting those rows would under-report a dorm's visitors. /** The counters a room starts life with (and the shape the client expects). */ const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 } @@ -2805,88 +2813,21 @@ export async function recordRoomVisit( ]) } -/** - * Cheer/favorite counts per room, aggregated from `interaction` by a grouped query. - * Restricted to `roomIds` when given (a feed page), otherwise covering every room. - * Rooms nobody has interacted with are absent. - * - * The filtered form is an index range per id on `idx_interaction_room`, whose columns - * cover the counters — the table is never read. That's why a page with more ids than - * D1 lets us bind is split into batched queries rather than served by the unfiltered - * scan: every room read runs this, and the scan cost the whole table per call. - */ -export async function getRoomStats( - db: D1Database, - roomIds?: number[] -): Promise> { - const byRoom = new Map() - if (roomIds && roomIds.length === 0) return byRoom - const statement = (ids: number[]): D1PreparedStatement => { - const where = ids.length > 0 ? `WHERE room_id IN (${bindPlaceholders(ids)})` : '' - return db - .prepare( - `SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites - FROM interaction ${where} GROUP BY room_id` - ) - .bind(...ids) - } - const chunks = roomIds ? chunkForBinds(roomIds) : [[]] - const batches = - chunks.length === 1 - ? [await statement(chunks[0]!).all()] - : await db.batch(chunks.map(statement)) - for (const { results } of batches) { - for (const r of results) { - byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 }) - } - } - return byRoom -} - -/** - * Overwrite each room's derived counters from the interaction table, in one query for - * the whole batch. Callers that already aggregated (the feeds rank by these counts, so - * they need them before paging) pass their map in rather than paying for a second query. - */ -async function attachStats( - db: D1Database, - rooms: Room[], - stats?: Map -): Promise { - if (rooms.length === 0) return - const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))])) - for (const room of rooms) { - const counts = byRoom.get(roomIdOf(room)) - // `storedStats` zeroes VisitCount (the blob doesn't own it), so carry over the - // value `parseRow` folded in from the `visits` column rather than losing it here. - const stats = (room.Stats ?? {}) as Record - room.Stats = { - ...storedStats(stats), - VisitCount: typeof stats.VisitCount === 'number' ? stats.VisitCount : 0, - CheerCount: counts?.CheerCount ?? 0, - FavoriteCount: counts?.FavoriteCount ?? 0, - } - } -} - -/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */ +/** Hydrate a single room's `SubRooms` and `Tags` (no-op for null). */ async function hydrateRoom(db: D1Database, room: Room | null): Promise { if (room) await hydrateRooms(db, [room]) return room } /** - * Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each). + * Hydrate many rooms' `SubRooms` and `Tags` (one batched query each). `Stats` needs no + * hydration: its counters came off the row with the blob ({@link parseRow}). * * `Tags` is re-attached even for the feeds that already did so before ranking * ({@link parseAllWithTags}) — it is one query for the page slice and it guarantees the key * is present on every room this module hands out, whichever path produced it. */ -async function hydrateRooms( - db: D1Database, - rooms: Room[], - stats?: Map -): Promise { +async function hydrateRooms(db: D1Database, rooms: Room[]): Promise { for (const room of rooms) { // Studio enumerates these collections directly when opening Room Settings. for (const field of [ @@ -2901,11 +2842,7 @@ async function hydrateRooms( // game's Nullable decoder and discards the entire owned-room list. if (room.PublishedAt === '') room.PublishedAt = null } - await Promise.all([ - attachSubRooms(db, rooms), - attachTags(db, rooms), - attachStats(db, rooms, stats), - ]) + await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)]) return rooms } @@ -3378,7 +3315,7 @@ export async function getFavoritedRooms( ): Promise { const { results } = await db .prepare( - `SELECT r.data AS data, r.visits AS visits + `SELECT r.data AS data, ${COUNTER_COLUMNS} FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.favorited = 1 @@ -3439,7 +3376,7 @@ async function visitedRooms( // definition, as everywhere else it is used. const { results } = await db .prepare( - `SELECT r.data AS data, r.visits AS visits + `SELECT r.data AS data, ${COUNTER_COLUMNS} FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL @@ -3483,7 +3420,18 @@ export async function getInteraction( ) } -/** Upsert+toggle a single boolean column, returning the resulting interaction. */ +/** The `room` counter column an interaction flag is mirrored by. */ +const COUNTER_OF = { cheered: 'cheers', favorited: 'favorites' } as const + +/** + * Upsert+toggle a single boolean column, returning the resulting interaction. + * + * The room's counter moves in the same batch (one transaction): +1 when the flag lands + * on, -1 when it lands off, read off the row the first statement just wrote. A toggle + * always flips the flag, so the move is always exactly one, and concurrent toggles by + * different players each apply their own ±1 to the column rather than racing a + * read-modify-write. An unknown room id simply matches no `room` row. + */ async function toggleInteraction( db: D1Database, playerId: number, @@ -3493,8 +3441,9 @@ async function toggleInteraction( // First interaction defaults the toggled column to 1; subsequent calls flip it. // `last_visited_at` is left alone: only entering the room ({@link recordRoomVisit}) // is a visit, so a room cheered from a feed stays off the visited history. - return toInteraction( - await db + const counter = COUNTER_OF[column] + const [toggled] = await db.batch([ + db .prepare( `INSERT INTO interaction (player_id, room_id, ${column}) VALUES (?1, ?2, 1) @@ -3502,9 +3451,17 @@ async function toggleInteraction( DO UPDATE SET ${column} = NOT ${column} RETURNING cheered, favorited` ) - .bind(playerId, roomId) - .first() - ) + .bind(playerId, roomId), + db + .prepare( + `UPDATE room SET ${counter} = ${counter} + CASE + WHEN (SELECT ${column} FROM interaction WHERE player_id = ?1 AND room_id = ?2) = 1 + THEN 1 ELSE -1 END + WHERE room_id = ?2` + ) + .bind(playerId, roomId), + ]) + return toInteraction(toggled?.results[0] ?? null) } /** Toggle the player's cheer on a room, returning the resulting interaction. */ @@ -3530,6 +3487,10 @@ export async function toggleFavorite( * the cheer/favorite toggles). Idempotent: only clears an existing interaction row * and never creates one, so clearing a flag on a room the player never interacted * with doesn't add a spurious visited/favorited entry. Returns the interaction. + * + * The room's counter drops by one only when the flag was actually set — a repeated + * DELETE changes nothing and must not count down again — so the decrement is gated on + * the flag's state BEFORE the clear, in the same batch. */ async function clearInteraction( db: D1Database, @@ -3537,11 +3498,24 @@ async function clearInteraction( roomId: number, column: 'cheered' | 'favorited' ): Promise { - await db - .prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) - .bind(playerId, roomId) - .run() - return getInteraction(db, playerId, roomId) + const counter = COUNTER_OF[column] + const [, , after] = await db.batch([ + db + .prepare( + `UPDATE room SET ${counter} = ${counter} - 1 + WHERE room_id = ?2 AND EXISTS ( + SELECT 1 FROM interaction WHERE player_id = ?1 AND room_id = ?2 AND ${column} = 1 + )` + ) + .bind(playerId, roomId), + db + .prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) + .bind(playerId, roomId), + db + .prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2') + .bind(playerId, roomId), + ]) + return toInteraction(after?.results[0] ?? null) } /** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */ @@ -3708,14 +3682,14 @@ export async function autocompleteRoomSearch( /** * Engagement score used to order the hot feed (cheers weigh most, then favorites). - * Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking - * happens before hydration, so the room blob's copies are still zero at this point. + * Cheers/favorites are the `room` row's counter columns, which {@link parseRow} folds + * into `Stats` on a ranking stub as on a full room, so the feeds rank without reading + * `interaction` at all. */ -function hotScore(room: Room, stats: Map): number { - const counts = stats.get(roomIdOf(room)) - const stored = room.Stats as Record | null | undefined - const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0 - return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors +function hotScore(room: Room): number { + const stats = room.Stats as Record | null | undefined + const count = (key: string): number => (typeof stats?.[key] === 'number' ? stats[key] : 0) + return count('CheerCount') * 3 + count('FavoriteCount') * 2 + count('VisitorCount') } /** @@ -3848,19 +3822,12 @@ export async function getHotRooms( const players = await countPlayersByRoom(db) const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0 - const stats = await getRoomStats(db) rooms.sort( (a, b) => - playerCount(b) - playerCount(a) || - hotScore(b, stats) - hotScore(a, stats) || - roomIdOf(a) - roomIdOf(b) + playerCount(b) - playerCount(a) || hotScore(b) - hotScore(a) || roomIdOf(a) - roomIdOf(b) ) return { - Results: await hydrateRooms( - db, - await loadRankedPage(db, rooms.slice(skip, skip + take)), - stats - ), + Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))), TotalResults: rooms.length, } } @@ -3979,12 +3946,11 @@ export async function getRecommendedRooms( const { results } = await db .prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() - const stats = await getRoomStats(db) const page = parseAll(results) .filter(isListable) - .sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b)) + .sort((a, b) => hotScore(b) - hotScore(a) || roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take) - return hydrateRooms(db, await loadRankedPage(db, page), stats) + return hydrateRooms(db, await loadRankedPage(db, page)) } /** @@ -4011,23 +3977,16 @@ export async function getTrendingRooms( const { results } = await db .prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() - const stats = await getRoomStats(db) const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0 const rooms = parseAll(results) .filter((r) => isListable(r) && playerCount(r) > 0) .sort( (a, b) => - playerCount(b) - playerCount(a) || - hotScore(b, stats) - hotScore(a, stats) || - roomIdOf(a) - roomIdOf(b) + playerCount(b) - playerCount(a) || hotScore(b) - hotScore(a) || roomIdOf(a) - roomIdOf(b) ) return { - Results: await hydrateRooms( - db, - await loadRankedPage(db, rooms.slice(skip, skip + take)), - stats - ), + Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))), TotalResults: rooms.length, } } @@ -4133,7 +4092,6 @@ export async function getSimilarRooms( ) .bind(JSON.stringify([...targetTags]), roomId) .all() - const stats = await getRoomStats(db) const scored = results .map((row) => ({ room: parseRow(row), shared: row.shared })) @@ -4142,16 +4100,12 @@ export async function getSimilarRooms( scored.sort( (a, b) => b.shared - a.shared || - hotScore(b.room, stats) - hotScore(a.room, stats) || + hotScore(b.room) - hotScore(a.room) || roomIdOf(a.room) - roomIdOf(b.room) ) const rooms = scored.map((x) => x.room) return { - Results: await hydrateRooms( - db, - await loadRankedPage(db, rooms.slice(skip, skip + take)), - stats - ), + Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))), TotalResults: rooms.length, } }