[rooms] fix slow engagement counters

This commit is contained in:
Devin Zuczek
2026-10-20 21:03:54 -04:00
parent 5f3cdc5922
commit affdcb85e3
4 changed files with 167 additions and 170 deletions
@@ -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);
+6 -5
View File
@@ -149,11 +149,12 @@ export const RoomTagDto = z.object({
}) })
/** /**
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the * A room's engagement counters. `CheerCount`/`FavoriteCount` are the `room.cheers`/
* per-player `interaction` rows on every read. `VisitCount` is the room's lifetime * `favorites` columns, moved by the cheer/favorite toggles as they flip the per-player
* visits — the `room.visits` column, bumped by the `match` worker on every successful * `interaction` row (so a count is how many players currently have the flag set).
* matchmake into the room. Nothing records distinct visitors, so `VisitorCount` stays * `VisitCount` is the room's lifetime visits — the `room.visits` column, bumped by the
* at 0. * `match` worker on every successful matchmake into the room. Nothing records distinct
* visitors, so `VisitorCount` stays at 0.
*/ */
export const RoomStatsDto = z.object({ export const RoomStatsDto = z.object({
CheerCount: z.int(), CheerCount: z.int(),
+16 -1
View File
@@ -4769,7 +4769,7 @@ describe('rooms endpoints', () => {
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false }) 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 = { type Stats = {
CheerCount: number CheerCount: number
FavoriteCount: number FavoriteCount: number
@@ -4818,6 +4818,21 @@ describe('rooms endpoints', () => {
VisitCount: 0, 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), // 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 // 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. // survives the cheer/favorite aggregation rather than being zeroed by it.
+118 -164
View File
@@ -17,7 +17,7 @@
* database read-only and uses `getRoomById`. Each imports the subset it needs. * 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 { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db' import { countPlayersByRoom } from './presence-db'
import { resyncInstanceCapacity } from './room-instance-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 -- in the blob so a visit is one atomic UPDATE that can't lose a concurrent
-- read-modify-write of the whole room. -- read-modify-write of the whole room.
visits INTEGER NOT NULL DEFAULT 0, 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\` -- The two flags every public feed filters on, alongside \`is_dorm\`
-- (migrations/0014_room_listable.sql, which appends them here). Generated like the -- (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 -- 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) PRIMARY KEY (player_id, room_id)
)`, )`,
// Per-ROOM access (migrations/0021_interaction_room_index.sql): the primary key // 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 // serves the per-player reads; this serves deleting a room's rows ({@link deleteRoom})
// (`getRoomStats`) filters by room_id, which without this scanned the whole table. // and the one-off backfill of `room.cheers`/`favorites` (migration 0031). It was
// Covering — both counters are in the index, so the aggregate never touches the table. // 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)`, `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), // 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. // so re-banning someone already banned updates their row rather than appending.
@@ -2215,14 +2225,20 @@ export async function moveSubRoom(
interface RoomRow { interface RoomRow {
data: string data: string
visits: number visits: number
cheers: number
favorites: number
} }
/** /**
* The columns every room read selects. `visits` is authoritative for the room's * The columns every room read selects. `visits`, `cheers` and `favorites` are
* `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to * authoritative for the room's `Stats.VisitCount`/`CheerCount`/`FavoriteCount` (the blob
* come back with the blob on every read; a join aliases them (`r.data AS data`). * 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 * 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', 'Stats',
] ]
.map((key) => `'${key}', data -> '$.${key}'`) .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 * 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. * 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 parseRow = (row: RoomRow): Room => {
const room = JSON.parse(row.data) as 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) attachRoomDtoDefaults(room)
return 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 * 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), * 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 * 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 * 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 * 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} return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i}
ON f${i}.room_id = r.room_id` ON f${i}.room_id = r.room_id`
}) })
// `data`/`visits` — in {@link ROOM_COLUMNS} or the ranking stub {@link RANK_COLUMNS} — // `data` and the counters — in {@link ROOM_COLUMNS} or the ranking stub
// are unqualified but unambiguous: the joined subqueries expose only `room_id`. // {@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 } return { sql: `SELECT ${columns} FROM room r ${joins.join(' ')}${filter}`, binds }
} }
@@ -2732,31 +2755,16 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
} }
// ---- Room stats ----------------------------------------------------------- // ---- Room stats -----------------------------------------------------------
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than // A room's engagement counters are COLUMNS on `room` — `visits`, `cheers`, `favorites` —
// stored: they're recomputed on every read, so a cheer shows up immediately and the // read back with the blob on every read ({@link parseRow}) and never stored in the blob
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0 // (see {@link serializeRoom}). Each is bumped by an atomic `col = col ± 1` UPDATE: visits
// (see {@link serializeRoom}). // 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
// `VisitCount` is neither stored in the blob nor derived: it's the `room.visits` column, // the whole room would drop concurrent updates, and deriving the counts by aggregating
// incremented by {@link recordRoomVisit} on each matchmake and read back with the blob // `interaction` on every read (as this once did) cost a scan of the whole table per feed
// (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no // call. `VisitorCount` (distinct visitors) is still left as the blob has it:
// per-player row to count — and it can't live in the blob, where a read-modify-write of // `interaction.last_visited_at` is stamped per player by {@link recordRoomVisit}, but not
// the whole room would drop concurrent visits. `VisitorCount` (distinct visitors) is // for dorms, so counting those rows would under-report a dorm's visitors.
// 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
}
/** The counters a room starts life with (and the shape the client expects). */ /** The counters a room starts life with (and the shape the client expects). */
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 } const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
@@ -2805,88 +2813,21 @@ export async function recordRoomVisit(
]) ])
} }
/** /** Hydrate a single room's `SubRooms` and `Tags` (no-op for null). */
* 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<Map<number, RoomStats>> {
const byRoom = new Map<number, RoomStats>()
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<RoomStatsRow>()]
: await db.batch<RoomStatsRow>(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<number, RoomStats>
): Promise<void> {
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<string, unknown>
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). */
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> { async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
if (room) await hydrateRooms(db, [room]) if (room) await hydrateRooms(db, [room])
return 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 * `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 * ({@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. * is present on every room this module hands out, whichever path produced it.
*/ */
async function hydrateRooms( async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
db: D1Database,
rooms: Room[],
stats?: Map<number, RoomStats>
): Promise<Room[]> {
for (const room of rooms) { for (const room of rooms) {
// Studio enumerates these collections directly when opening Room Settings. // Studio enumerates these collections directly when opening Room Settings.
for (const field of [ for (const field of [
@@ -2901,11 +2842,7 @@ async function hydrateRooms(
// game's Nullable<DateTime> decoder and discards the entire owned-room list. // game's Nullable<DateTime> decoder and discards the entire owned-room list.
if (room.PublishedAt === '') room.PublishedAt = null if (room.PublishedAt === '') room.PublishedAt = null
} }
await Promise.all([ await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
attachSubRooms(db, rooms),
attachTags(db, rooms),
attachStats(db, rooms, stats),
])
return rooms return rooms
} }
@@ -3378,7 +3315,7 @@ export async function getFavoritedRooms(
): Promise<Room[]> { ): Promise<Room[]> {
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT r.data AS data, r.visits AS visits `SELECT r.data AS data, ${COUNTER_COLUMNS}
FROM interaction i FROM interaction i
JOIN room r ON r.room_id = i.room_id JOIN room r ON r.room_id = i.room_id
WHERE i.player_id = ?1 AND i.favorited = 1 WHERE i.player_id = ?1 AND i.favorited = 1
@@ -3439,7 +3376,7 @@ async function visitedRooms(
// definition, as everywhere else it is used. // definition, as everywhere else it is used.
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT r.data AS data, r.visits AS visits `SELECT r.data AS data, ${COUNTER_COLUMNS}
FROM interaction i FROM interaction i
JOIN room r ON r.room_id = i.room_id JOIN room r ON r.room_id = i.room_id
WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL 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( async function toggleInteraction(
db: D1Database, db: D1Database,
playerId: number, playerId: number,
@@ -3493,8 +3441,9 @@ async function toggleInteraction(
// First interaction defaults the toggled column to 1; subsequent calls flip it. // First interaction defaults the toggled column to 1; subsequent calls flip it.
// `last_visited_at` is left alone: only entering the room ({@link recordRoomVisit}) // `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. // is a visit, so a room cheered from a feed stays off the visited history.
return toInteraction( const counter = COUNTER_OF[column]
await db const [toggled] = await db.batch<InteractionRow>([
db
.prepare( .prepare(
`INSERT INTO interaction (player_id, room_id, ${column}) `INSERT INTO interaction (player_id, room_id, ${column})
VALUES (?1, ?2, 1) VALUES (?1, ?2, 1)
@@ -3502,9 +3451,17 @@ async function toggleInteraction(
DO UPDATE SET ${column} = NOT ${column} DO UPDATE SET ${column} = NOT ${column}
RETURNING cheered, favorited` RETURNING cheered, favorited`
) )
.bind(playerId, roomId) .bind(playerId, roomId),
.first<InteractionRow>() 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. */ /** 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 * 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 * 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. * 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( async function clearInteraction(
db: D1Database, db: D1Database,
@@ -3537,11 +3498,24 @@ async function clearInteraction(
roomId: number, roomId: number,
column: 'cheered' | 'favorited' column: 'cheered' | 'favorited'
): Promise<Interaction> { ): Promise<Interaction> {
await db const counter = COUNTER_OF[column]
.prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) const [, , after] = await db.batch<InteractionRow>([
.bind(playerId, roomId) db
.run() .prepare(
return getInteraction(db, playerId, roomId) `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. */ /** 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). * 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 * Cheers/favorites are the `room` row's counter columns, which {@link parseRow} folds
* happens before hydration, so the room blob's copies are still zero at this point. * 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, RoomStats>): number { function hotScore(room: Room): number {
const counts = stats.get(roomIdOf(room)) const stats = room.Stats as Record<string, unknown> | null | undefined
const stored = room.Stats as Record<string, unknown> | null | undefined const count = (key: string): number => (typeof stats?.[key] === 'number' ? stats[key] : 0)
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0 return count('CheerCount') * 3 + count('FavoriteCount') * 2 + count('VisitorCount')
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
} }
/** /**
@@ -3848,19 +3822,12 @@ export async function getHotRooms(
const players = await countPlayersByRoom(db) const players = await countPlayersByRoom(db)
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0 const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
const stats = await getRoomStats(db)
rooms.sort( rooms.sort(
(a, b) => (a, b) =>
playerCount(b) - playerCount(a) || playerCount(b) - playerCount(a) || hotScore(b) - hotScore(a) || roomIdOf(a) - roomIdOf(b)
hotScore(b, stats) - hotScore(a, stats) ||
roomIdOf(a) - roomIdOf(b)
) )
return { return {
Results: await hydrateRooms( Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))),
db,
await loadRankedPage(db, rooms.slice(skip, skip + take)),
stats
),
TotalResults: rooms.length, TotalResults: rooms.length,
} }
} }
@@ -3979,12 +3946,11 @@ export async function getRecommendedRooms(
const { results } = await db const { results } = await db
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>() .all<RoomRow>()
const stats = await getRoomStats(db)
const page = parseAll(results) const page = parseAll(results)
.filter(isListable) .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) .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 const { results } = await db
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>() .all<RoomRow>()
const stats = await getRoomStats(db)
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0 const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
const rooms = parseAll(results) const rooms = parseAll(results)
.filter((r) => isListable(r) && playerCount(r) > 0) .filter((r) => isListable(r) && playerCount(r) > 0)
.sort( .sort(
(a, b) => (a, b) =>
playerCount(b) - playerCount(a) || playerCount(b) - playerCount(a) || hotScore(b) - hotScore(a) || roomIdOf(a) - roomIdOf(b)
hotScore(b, stats) - hotScore(a, stats) ||
roomIdOf(a) - roomIdOf(b)
) )
return { return {
Results: await hydrateRooms( Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))),
db,
await loadRankedPage(db, rooms.slice(skip, skip + take)),
stats
),
TotalResults: rooms.length, TotalResults: rooms.length,
} }
} }
@@ -4133,7 +4092,6 @@ export async function getSimilarRooms(
) )
.bind(JSON.stringify([...targetTags]), roomId) .bind(JSON.stringify([...targetTags]), roomId)
.all<RoomRow & { shared: number }>() .all<RoomRow & { shared: number }>()
const stats = await getRoomStats(db)
const scored = results const scored = results
.map((row) => ({ room: parseRow(row), shared: row.shared })) .map((row) => ({ room: parseRow(row), shared: row.shared }))
@@ -4142,16 +4100,12 @@ export async function getSimilarRooms(
scored.sort( scored.sort(
(a, b) => (a, b) =>
b.shared - a.shared || b.shared - a.shared ||
hotScore(b.room, stats) - hotScore(a.room, stats) || hotScore(b.room) - hotScore(a.room) ||
roomIdOf(a.room) - roomIdOf(b.room) roomIdOf(a.room) - roomIdOf(b.room)
) )
const rooms = scored.map((x) => x.room) const rooms = scored.map((x) => x.room)
return { return {
Results: await hydrateRooms( Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))),
db,
await loadRankedPage(db, rooms.slice(skip, skip + take)),
stats
),
TotalResults: rooms.length, TotalResults: rooms.length,
} }
} }