[rooms] improve tag query performance

This commit is contained in:
Devin Zuczek
2026-08-20 14:45:29 -04:00
parent 5db02db172
commit 44be2e071d
3 changed files with 600 additions and 59 deletions
+43
View File
@@ -0,0 +1,43 @@
-- Room tags as their own table, mirroring the `api` worker's `event_tag` (0010). One row
-- per tag per room. Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) —
-- keep in sync.
--
-- The table is AUTHORITATIVE and the blob's `Tags` key is removed below, the same
-- arrangement 0007/0008 gave subrooms and their saves: one place a tag is stored, so the
-- two can't drift. `serializeRoom` drops `Tags` on write and the reads re-attach it, so
-- the room DTO the client sees is unchanged.
--
-- The point is the lookup. Every tag-filtered read — a discovery category row
-- (`/algorithmiclists/quests_algoendpoint`), a `#tag` room search, the `base` template
-- list — used to SELECT every room and parse each blob just to ask what it was tagged.
-- They now narrow in SQL off `idx_room_tag_tag` and read only the blobs that match.
--
-- `tag` is stored lowercased: it is the lookup key, and every comparison in rooms-db.ts
-- was already case-insensitive, so nothing downstream can tell. `type` is the client's
-- tag-category int — 0 for a user tag, 2 for the auto-derived ones like `rro` — echoed
-- back as stored.
CREATE TABLE IF NOT EXISTS room_tag (
room_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag);
-- Backfill from the blobs. `json_each` walks the `Tags` array; a room with no array (or a
-- null one) contributes nothing, which is why this is a join rather than a correlated
-- subquery. `INSERT OR IGNORE` collapses a room that somehow carries the same tag twice
-- in different casing — the primary key is the lowercased name.
INSERT OR IGNORE INTO room_tag (room_id, tag, type)
SELECT
r.room_id,
lower(json_extract(t.value, '$.Tag')),
COALESCE(json_extract(t.value, '$.Type'), 0)
FROM room r, json_each(r.data, '$.Tags') t
WHERE json_extract(t.value, '$.Tag') IS NOT NULL;
-- Single source of truth: the tags now live in `room_tag`, so the copy in the blob goes.
-- Leaving it would be a second answer to "what is this room tagged" that only the writes
-- through toggleRoomTag keep current.
UPDATE room SET data = json_remove(data, '$.Tags');
+121
View File
@@ -2299,6 +2299,127 @@ describe('rooms endpoints', () => {
expect(tagsIn(byCoOwner)).toContain('spooky') expect(tagsIn(byCoOwner)).toContain('spooky')
}) })
// Tags live in `room_tag`, not in the room blob (migration 0013). These pin the
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
// from it on read.
it('stores tags in room_tag and never in the room blob', async () => {
await putForm('/rooms/2/tags', { tag: 'ghostly' }, '1')
// The blob must carry no `Tags` key at all — a second copy is what the table exists
// to eliminate, and it would only be kept current by the writes that go through
// toggleRoomTag.
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = 2').first<{
data: string
}>()
expect(JSON.parse(row!.data).Tags).toBeUndefined()
// The table has it, lowercased — `tag` is the lookup key the index is on.
const tagRow = await env.DB.prepare(
'SELECT tag, type FROM room_tag WHERE room_id = 2 AND tag = ?1'
)
.bind('ghostly')
.first<{ tag: string; type: number }>()
expect(tagRow).toEqual({ tag: 'ghostly', type: 0 })
// And the read re-attaches it, so the client sees an unchanged DTO.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Tags: Array<{ Tag: string; Type: number }>
}
expect(room.Tags).toContainEqual({ Tag: 'ghostly', Type: 0 })
// Toggle back off: the row goes, rather than lingering to answer `#ghostly` searches.
await putForm('/rooms/2/tags', { tag: 'ghostly' }, '1')
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_tag WHERE room_id = 2 AND tag = ?1')
.bind('ghostly')
.first<{ n: number }>()
).toEqual({ n: 0 })
})
// D1 caps a statement at 100 bound parameters. The seeded database has 45 rooms, so
// every `IN (…)` list built per-room fitted and the cap went unnoticed until a real
// server crossed it: the hot feed attaches tags to EVERY room, so the bind list grew
// with the database and the query failed with "variable number must be between ?1 and
// ?100". This seeds past the cap so the feeds are exercised above it.
it('serves the feeds when there are more rooms than D1 allows bound parameters', async () => {
const FIRST = 20000
const COUNT = 150
for (let i = 0; i < COUNT; i++) {
const roomId = FIRST + i
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: roomId,
Name: `BulkRoom${i}`,
CreatorAccountId: 700,
IsDorm: false,
Accessibility: 1,
CreatedAt: '2026-05-01T00:00:00Z',
SubRooms: [],
})
)
.run()
// Every third one tagged, so the tag map is non-trivial above the cap too.
if (i % 3 === 0) {
await env.DB.prepare('INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, 0)')
.bind(roomId, 'bulky')
.run()
}
}
// The feed that failed: it reads every room, so it attaches tags for all of them.
const hot = await SELF.fetch(`${ORIGIN}/rooms/hot?take=5`)
expect(hot.status).toBe(200)
// Rooms owned by one account — this hydrates the WHOLE result set rather than a page,
// so its subroom and save reads run above the cap too. Those had the same latent bug.
const mine = await SELF.fetch(`${ORIGIN}/rooms/createdby/me`, { headers: await bearer('700') })
expect(mine.status).toBe(200)
expect(((await mine.json()) as unknown[]).length).toBe(COUNT)
// And the tag lookup still answers correctly above the cap.
const tagged = (await (
await SELF.fetch(`${ORIGIN}/rooms/search?query=%23bulky&take=200`)
).json()) as { Results: Array<{ RoomId: number }> }
expect(tagged.Results.length).toBe(Math.ceil(COUNT / 3))
// Clean up so the room counts other tests assert stay put.
await env.DB.prepare('DELETE FROM room WHERE room_id >= ?1').bind(FIRST).run()
await env.DB.prepare('DELETE FROM room_tag WHERE room_id >= ?1').bind(FIRST).run()
})
it("drops a deleted room's tag rows", async () => {
// Throwaway room owned by account 1, seeded directly the way the DELETE test above
// does, so this doesn't disturb the imported rooms.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 9600,
Name: 'TagCascadeRoom',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
SubRooms: [],
})
)
.run()
await putForm('/rooms/9600/tags', { tag: 'doomed' }, '1')
const count = async () =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM room_tag WHERE room_id = 9600').first<{
n: number
}>())!.n
expect(await count()).toBe(1)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/9600`, { method: 'DELETE', headers: await bearer('1') }))
.status
).toBe(200)
// Otherwise the tag rows keep answering `#doomed` searches and category rows for a
// room nobody can open.
expect(await count()).toBe(0)
})
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => { it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
// No token → 401 (auth gate). // No token → 401 (auth gate).
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401) expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
+433 -56
View File
@@ -39,6 +39,22 @@ export const ROOM_SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`, `CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`, `CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`,
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
// `subroom_save` already use, so the two can't drift.
//
// `tag` is stored lowercased and is the lookup key, which is what lets a tag-filtered
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
// auto-derived ones like `rro` — echoed back as stored.
`CREATE TABLE IF NOT EXISTS room_tag (
room_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
// Per-player interaction state with a room (cheered/favorited + last visit). // Per-player interaction state with a room (cheered/favorited + last visit).
// One row per (player, room); cheer/favorite are toggled in place. // One row per (player, room); cheer/favorite are toggled in place.
`CREATE TABLE IF NOT EXISTS interaction ( `CREATE TABLE IF NOT EXISTS interaction (
@@ -413,9 +429,13 @@ export async function setRoomRole(
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art']) const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
/** /**
* Add a user tag (`Type: 0`) to a room's `Tags`, skipping it when already present * Add a user tag (`Type: 0`) to a room's tags, or remove it when it's already there
* (case-insensitive). The caller supplies the already-loaded room (owner-checked) * (case-insensitive). The caller supplies the already-loaded room (owner-checked) to avoid
* to avoid a re-read; the whole room JSON is rewritten. Returns the updated room. * a re-read. Returns the updated room.
*
* Only `room_tag` is written — the room blob no longer carries tags at all, so the room row
* is left alone. The whole resulting set is written rather than a single insert/delete, so
* the radio-button behaviour below stays one atomic batch.
*/ */
export async function toggleRoomTag( export async function toggleRoomTag(
db: D1Database, db: D1Database,
@@ -423,15 +443,15 @@ export async function toggleRoomTag(
room: Room, room: Room,
tag: string tag: string
): Promise<Room> { ): Promise<Room> {
const tags = Array.isArray(room.Tags) ? (room.Tags as Array<Record<string, unknown>>) : [] const tags = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
const lower = tag.toLowerCase() const lower = tag.toLowerCase()
const tagLower = (t: Record<string, unknown>): string => String(t?.Tag).toLowerCase() const tagLower = (t: RoomTag): string => String(t?.Tag).toLowerCase()
const existing = tags.findIndex((t) => tagLower(t) === lower) const existing = tags.findIndex((t) => tagLower(t) === lower)
// The client has no delete/patch endpoint — the same call toggles a tag: remove // The client has no delete/patch endpoint — the same call toggles a tag: remove
// it if already present, add it otherwise. Adding a main tag is a radio pick, so // it if already present, add it otherwise. Adding a main tag is a radio pick, so
// it also clears any other main tag already set. // it also clears any other main tag already set.
let nextTags: Array<Record<string, unknown>> let nextTags: RoomTag[]
if (existing !== -1) { if (existing !== -1) {
nextTags = tags.filter((_, i) => i !== existing) nextTags = tags.filter((_, i) => i !== existing)
} else if (MAIN_TAGS.has(lower)) { } else if (MAIN_TAGS.has(lower)) {
@@ -440,12 +460,10 @@ export async function toggleRoomTag(
nextTags = [...tags, { Tag: tag, Type: 0 }] nextTags = [...tags, { Tag: tag, Type: 0 }]
} }
const updated: Room = { ...room, Tags: nextTags } await setRoomTags(db, roomId, nextTags)
await db // Reflect what was just stored, lowercased the way the table holds it, so the caller
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') // answers the client with the tags a re-read would give it.
.bind(roomId, serializeRoom(updated)) return { ...room, Tags: nextTags.map((t) => ({ Tag: t.Tag.toLowerCase(), Type: t.Type })) }
.run()
return updated
} }
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */ /** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
@@ -904,6 +922,52 @@ const parseRow = (row: RoomRow): Room => {
const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null) const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null)
const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow) const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow)
/**
* D1 caps a prepared statement at 100 bound parameters — binding more fails outright with
* "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's
* array has to respect this, which is easy to miss: a seeded dev database has fewer than a
* hundred rooms, so an unchunked query works right up until it meets a real one.
*/
const MAX_BOUND_PARAMS = 100
/**
* Split values into chunks that fit {@link MAX_BOUND_PARAMS}, for the reads whose rows are
* too heavy to fetch wholesale (subroom and save blobs) and so have to page through an
* `IN (…)` rather than scan.
*/
function chunkForBinds<T>(values: T[]): T[][] {
const chunks: T[][] = []
for (let i = 0; i < values.length; i += MAX_BOUND_PARAMS) {
chunks.push(values.slice(i, i + MAX_BOUND_PARAMS))
}
return chunks
}
/**
* Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the
* placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each
* chunk is its own statement.
*
* Rows come back in chunk order, and each chunk is ordered by whatever `sql` says. Callers
* that group by a key stay correct as long as a key's rows can't straddle two chunks —
* true for both callers here, which chunk BY that key.
*/
async function selectInChunks<Row>(
db: D1Database,
ids: number[],
sql: (placeholders: string) => string
): Promise<Row[]> {
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(sql(chunk.map((_, i) => `?${i + 1}`).join(',')))
.bind(...chunk)
.all<Row>()
)
)
return pages.flatMap((page) => page.results)
}
// ---- Subrooms ------------------------------------------------------------- // ---- Subrooms -------------------------------------------------------------
// Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a // Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a
// room's `SubRooms` array is reconstructed on read and never stored in the room blob. // room's `SubRooms` array is reconstructed on read and never stored in the room blob.
@@ -954,13 +1018,18 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
} }
/** /**
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never * Serialize a room for a full-blob write, dropping the parts that belong to another table
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job) and * so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job),
* zeroing the derived engagement counters (those are the `interaction` table's job — see * `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement
* {@link attachStats}), so a write can't bake a snapshot of them into the blob. * counters, which are zeroed rather than dropped so the key stays present (the
* `interaction` table's job — see {@link attachStats}).
*
* 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
* it.
*/ */
const serializeRoom = (room: Room): string => { const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, Stats: stats, ...rest } = room const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) }) return JSON.stringify({ ...rest, Stats: storedStats(stats) })
} }
@@ -980,14 +1049,16 @@ async function attachCurrentSaves(
const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))] const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
const byId = new Map<number, SubRoomDataSave>() const byId = new Map<number, SubRoomDataSave>()
if (saveIds.length > 0) { if (saveIds.length > 0) {
const placeholders = saveIds.map((_, i) => `?${i + 1}`).join(',') // Chunked: a save row carries a whole scene, so these are fetched by id rather than
const { results } = await db // scanned, and the id list can exceed D1's bound-parameter cap once enough rooms are
.prepare( // hydrated at once.
const results = await selectInChunks<SubRoomSaveRow>(
db,
saveIds,
(placeholders) =>
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
WHERE sub_room_data_save_id IN (${placeholders})` WHERE sub_room_data_save_id IN (${placeholders})`
) )
.bind(...saveIds)
.all<SubRoomSaveRow>()
for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r)) for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r))
} }
subs.forEach((sub, i) => { subs.forEach((sub, i) => {
@@ -996,6 +1067,155 @@ async function attachCurrentSaves(
}) })
} }
// ---- Room tags ------------------------------------------------------------
// A room's tags live in `room_tag`, not in the room blob (see ROOM_SCHEMA_DDL). The blob
// is stripped on write and the array is re-attached on read, so there is exactly one
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
// parses every room to ask.
/** One of a room's tags, as the client's room DTO carries it. */
export interface RoomTag {
Tag: string
Type: number
}
interface RoomTagRow {
room_id: number
tag: string
type: number
}
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
const byRoom = new Map<number, RoomTag[]>()
for (const row of rows) {
const list = byRoom.get(row.room_id) ?? []
list.push({ Tag: row.tag, Type: row.type })
byRoom.set(row.room_id, list)
}
return byRoom
}
/**
* Every tag of the given rooms, keyed by RoomId, in ONE query however many rooms are
* asked about. An empty `roomIds` reads nothing rather than every tag in the table — an
* empty `IN ()` isn't valid SQL, and "no rooms asked about" must not come to mean "all of
* them".
*
* Two shapes, because the callers are two different questions. A page slice fits D1's
* 100-parameter cap and is fetched by id. Attaching tags to EVERY room does not fit — and
* chunking it would mean ceil(n/100) round trips to answer what one unfiltered read
* answers, on a table of three small columns. So past the cap this reads the whole table
* and narrows in memory.
*
* This is what the D1 error "variable number must be between ?1 and ?100" was: the hot feed
* attaches tags to every room, so the bound list grew with the database and the query blew
* up the moment a server had more than a hundred rooms.
*
* Tags come back alphabetical, so a room's array is stable between reads.
*/
async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number, RoomTag[]>> {
const ids = [...new Set(roomIds)]
if (ids.length === 0) return new Map()
if (ids.length > MAX_BOUND_PARAMS) {
const { results } = await db
.prepare('SELECT room_id, tag, type FROM room_tag ORDER BY tag')
.all<RoomTagRow>()
const wanted = new Set(ids)
return groupTags(results.filter((row) => wanted.has(row.room_id)))
}
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(
`SELECT room_id, tag, type FROM room_tag
WHERE room_id IN (${placeholders}) ORDER BY tag`
)
.bind(...ids)
.all<RoomTagRow>()
return groupTags(results)
}
/**
* Fill in each room's `Tags` from `room_tag`. Every room ends up with the key PRESENT —
* an empty array when it carries none — because the client's DTO has a non-nullable
* `Tags` and the blob no longer supplies one.
*/
async function attachTags(db: D1Database, rooms: Room[]): Promise<void> {
const byRoom = await tagsByRoom(db, [...new Set(rooms.map(roomIdOf))])
for (const room of rooms) room.Tags = byRoom.get(roomIdOf(room)) ?? []
}
/**
* Parse room rows AND attach their tags — the read every scan-then-rank feed starts from.
*
* Those feeds filter and sort BEFORE they hydrate (ranking a room doesn't need its
* subrooms), but several of them rank ON tags, so the tags have to be present earlier than
* {@link hydrateRooms} would put them. One extra query for the whole batch.
*/
async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]> {
const rooms = parseAll(rows)
await attachTags(db, rooms)
return rooms
}
/**
* Replace a room's tags with the given set, in one batch. A replace and not a merge: the
* only writer ({@link toggleRoomTag}) computes the whole set it wants, so a removed tag is
* a write with that tag left out.
*
* Tags are stored LOWERCASED, which is what makes the index a usable lookup key — every
* comparison in this module was already case-insensitive, so nothing downstream can tell
* the difference. A room tagged `Horror` reads back `horror`.
*/
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
for (const { Tag, Type } of tags) {
statements.push(
db
.prepare(
`INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, ?3)
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3`
)
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0)
)
}
await db.batch(statements)
}
/**
* A room read narrowed to the rooms carrying EVERY one of `tagSets` — one set per tag the
* caller requires, and a room matches a set by carrying ANY tag in it (which is how a
* term's aliases work: `#recroomoriginal` accepts `rro`). An empty `tagSets` reads every
* room.
*
* A JOIN driven from `room_tag`, not a `WHERE EXISTS`, and the difference is the whole
* point of the table. EXPLAIN QUERY PLAN on the seeded database:
*
* WHERE EXISTS … SCAN room · SEARCH room_tag USING COVERING INDEX (room_id=? AND tag=?)
* JOIN from tags SEARCH room_tag USING INDEX idx_room_tag_tag (tag=?)
* SEARCH room USING INDEX idx_rooms_room_id (room_id=?)
*
* The EXISTS form still walks every room and probes the index once per room, so it costs
* what the in-memory filter it replaced cost. The join searches the tag index FIRST and
* then looks up only the rooms that matched, which is what makes a category row cheap.
*/
function roomsByTagsQuery(tagSets: string[][]): { sql: string; binds: string[] } {
if (tagSets.length === 0) return { sql: `SELECT ${ROOM_COLUMNS} FROM room`, binds: [] }
const binds: string[] = []
const joins = tagSets.map((tags, i) => {
const placeholders = tags.map((_, j) => `?${binds.length + j + 1}`).join(', ')
binds.push(...tags)
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` are unqualified but unambiguous: the joined subqueries expose only
// `room_id`.
return { sql: `SELECT ${ROOM_COLUMNS} FROM room r ${joins.join(' ')}`, binds }
}
/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */ /** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> { async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> {
const subs = rows.map(parseSubRoomRow) const subs = rows.map(parseSubRoomRow)
@@ -1010,14 +1230,15 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
for (const room of rooms) room.SubRooms = [] for (const room of rooms) room.SubRooms = []
return return
} }
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') // Chunked by ROOM, so all of a room's subrooms land in one chunk and stay ordered
const { results } = await db // relative to each other — the grouping below depends on that.
.prepare( const results = await selectInChunks<SubRoomRow>(
db,
ids,
(placeholders) =>
`SELECT ${SUBROOM_COLUMNS} FROM subroom `SELECT ${SUBROOM_COLUMNS} FROM subroom
WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` WHERE room_id IN (${placeholders}) ORDER BY sub_room_id`
) )
.bind(...ids)
.all<SubRoomRow>()
const subs = await parseSubRoomRows(db, results) const subs = await parseSubRoomRows(db, results)
const byRoom = new Map<number, SubRoom[]>() const byRoom = new Map<number, SubRoom[]>()
results.forEach((r, i) => { results.forEach((r, i) => {
@@ -1143,13 +1364,23 @@ async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | nu
return room return room
} }
/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */ /**
* Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each).
*
* `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( async function hydrateRooms(
db: D1Database, db: D1Database,
rooms: Room[], rooms: Room[],
stats?: Map<number, RoomStats> stats?: Map<number, RoomStats>
): Promise<Room[]> { ): Promise<Room[]> {
await Promise.all([attachSubRooms(db, rooms), attachStats(db, rooms, stats)]) await Promise.all([
attachSubRooms(db, rooms),
attachTags(db, rooms),
attachStats(db, rooms, stats),
])
return rooms return rooms
} }
@@ -1431,6 +1662,11 @@ export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<
const roomId = Number(room.RoomId) const roomId = Number(room.RoomId)
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
// `serializeRoom` drops `Tags`, so a seeded room's tags have to go to their own table or
// they'd vanish — the same step the migration's backfill takes for the imported rooms.
if (Array.isArray(room.Tags) && room.Tags.length > 0) {
await setRoomTags(db, roomId, room.Tags as RoomTag[])
}
for (const sub of subRooms) { for (const sub of subRooms) {
const subRoomId = Number(sub.SubRoomId) const subRoomId = Number(sub.SubRoomId)
await db await db
@@ -1469,6 +1705,9 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
await db.batch([ await db.batch([
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
// Tag rows outlive the blob otherwise, and would keep answering `#tag` searches and
// category rows for a room nobody can open.
db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId),
// Saves and permission overrides first — both are keyed by subroom, so they'd be // Saves and permission overrides first — both are keyed by subroom, so they'd be
// unreachable once the subrooms themselves are gone. // unreachable once the subrooms themselves are gone.
db db
@@ -1577,9 +1816,7 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr
* <player>" list (excludes private rooms, dorms, and list-excluded rooms). * <player>" list (excludes private rooms, dorms, and list-excluded rooms).
*/ */
export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> { export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
return (await getRoomsByCreator(db, accountId)).filter( return (await getRoomsByCreator(db, accountId)).filter(isListable)
(r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true
)
} }
/** /**
@@ -1780,18 +2017,27 @@ export async function searchRooms(
if (q === '') return { Results: [], TotalResults: 0 } if (q === '') return { Results: [], TotalResults: 0 }
const terms = q.split(/[\s+]+/).filter(Boolean) const terms = q.split(/[\s+]+/).filter(Boolean)
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>() // The `#tag` terms narrow in SQL — one EXISTS per term, because a room has to carry
// EVERY tag asked for, and each term expands to its aliases (`#recroomoriginal` accepts
// `rro`). Only the rooms that survive have their blobs read, which is what `room_tag` is
// for: a tag search no longer parses every room in the database to ask.
const tagSets = terms
.filter((t) => t.startsWith('#'))
.map((t) => t.slice(1))
.map((tag) => [tag, ...(TAG_ALIASES[tag] ?? [])])
const { sql, binds } = roomsByTagsQuery(tagSets)
const { results } = await db
.prepare(sql)
.bind(...binds)
.all<RoomRow>()
let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)
// The plain terms still match in memory: they are substring matches on the name, which
// no index helps with.
for (const term of terms) { for (const term of terms) {
if (term.startsWith('#')) { if (term.startsWith('#')) continue
const tag = term.slice(1)
const accepted = new Set([tag, ...(TAG_ALIASES[tag] ?? [])])
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
} else {
rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term)) rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term))
} }
}
return { return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
@@ -1828,7 +2074,11 @@ export async function autocompleteRoomSearch(
if (q === '' || take <= 0) return [] if (q === '' || take <= 0) return []
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>() const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
const rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) // Tags attached up front: suggestions are drawn from them, and this reads every room for
// its NAME regardless, so the tags cost one extra query rather than a second scan.
const rooms = (await parseAllWithTags(db, results)).filter(
(r) => r.IsDorm !== true && r.Accessibility === 1
)
const tagQuery = q.startsWith('#') const tagQuery = q.startsWith('#')
const term = tagQuery ? q.slice(1) : q const term = tagQuery ? q.slice(1) : q
@@ -1900,6 +2150,28 @@ function isRRO(room: Room): boolean {
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro'])) return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
} }
/**
* True when the room may appear in a browse or discovery feed at all: public, not a dorm,
* and not opted out of lists. Every feed below starts from this, so a room that opts out
* cannot come back through a row that forgot to check.
*/
function isListable(room: Room): boolean {
return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromLists !== true
}
/**
* True when the room is a PLAYER's rather than one of this server's stock ones — the same
* test {@link COMMUNITY_TAG} applies, since the Coach account owns every seeded room.
*
* A different question from {@link isRRO}, which asks whether a room is a Rec Room
* Original. The two agree on the data as it stands (every seeded room is Coach-owned AND
* flagged `rro`), but the discovery rows ask this one: a stock room that was never flagged
* still isn't something a player built.
*/
function isPlayerMade(room: Room): boolean {
return room.CreatorAccountId !== COACH_ACCOUNT_ID
}
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */ /** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
function createdAt(room: Room): number { function createdAt(room: Room): number {
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
@@ -1925,12 +2197,19 @@ export async function getHotRooms(
skip: number, skip: number,
take: number take: number
): Promise<{ Results: Room[]; TotalResults: number }> { ): Promise<{ Results: Room[]; TotalResults: number }> {
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
let rooms = parseAll(results).filter(
(r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true
)
const t = tag.trim().toLowerCase() const t = tag.trim().toLowerCase()
// A REAL tag narrows in SQL — this is the query `room_tag` exists for, and the one a
// discovery category row runs: only the rooms carrying the tag have their blobs read.
// The two pseudo-tags below name no tag at all, so they still scan.
const isPseudo = t === '' || t === NEW_TAG || t === COMMUNITY_TAG
const { sql, binds } = roomsByTagsQuery(isPseudo ? [] : [[t, ...(TAG_ALIASES[t] ?? [])]])
const { results } = await db
.prepare(sql)
.bind(...binds)
.all<RoomRow>()
let rooms = (await parseAllWithTags(db, results)).filter(isListable)
if (t === NEW_TAG) { if (t === NEW_TAG) {
// Newest player-made rooms first; RoomId (which is minted in creation order) // Newest player-made rooms first; RoomId (which is minted in creation order)
// breaks ties so rooms created in the same instant still page stably. // breaks ties so rooms created in the same instant still page stably.
@@ -1943,11 +2222,10 @@ export async function getHotRooms(
} }
} }
// `community` is a pseudo-tag: it filters on who MADE the room rather than on any tag,
// so it can't be pushed into the tag query above. A real tag already narrowed there.
if (t === COMMUNITY_TAG) { if (t === COMMUNITY_TAG) {
rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID) rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID)
} else if (t !== '') {
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
} }
const players = await countPlayersByRoom(db) const players = await countPlayersByRoom(db)
@@ -1965,6 +2243,100 @@ export async function getHotRooms(
} }
} }
/**
* When each room's live scene was last PUBLISHED, as epoch millis keyed by RoomId: the
* newest `CurrentSave.CreatedAt` across the room's subrooms.
*
* Published, not merely saved. A staged save bumps the subroom's `DataSavedAt` but changes
* nothing anyone else can load, so ordering by that would float rooms whose visible content
* never moved — only `current_save_id`, what the loader actually serves, counts here.
*
* A room with no published save is ABSENT from the map rather than mapped to 0, so the
* caller can tell "never published" from "published at the epoch" and choose its own
* fallback.
*/
async function lastPublishedAtByRoom(db: D1Database): Promise<Map<number, number>> {
// `json_extract` rather than parsing the row: a save blob carries the whole scene and
// only its timestamp is wanted, so the DataBlob never has to cross the wire.
const { results } = await db
.prepare(
`SELECT s.room_id AS room_id, json_extract(sv.data, '$.CreatedAt') AS created_at
FROM subroom s JOIN subroom_save sv ON sv.sub_room_data_save_id = s.current_save_id`
)
.all<{ room_id: number; created_at: string | null }>()
const latest = new Map<number, number>()
for (const row of results) {
const ts = typeof row.created_at === 'string' ? Date.parse(row.created_at) : NaN
if (Number.isNaN(ts)) continue
const seen = latest.get(row.room_id)
if (seen === undefined || ts > seen) latest.set(row.room_id, ts)
}
return latest
}
/**
* The "recently updated" discovery row: listable, player-made rooms ordered by when their
* live scene was last PUBLISHED, newest first.
*
* The Coach account's rooms are left out for the reason {@link COMMUNITY_TAG} leaves them
* out — they are this server's stock rooms, and a row about what people have been building
* should not be a row about the seed data.
*
* A room that has never published a save falls back to its own `CreatedAt`: creating a room
* IS its first update, and on a fresh server that is the only timestamp any room has, so
* dropping them would leave the row empty. RoomId — minted in creation order — breaks ties
* newest-first so paging stays stable.
*
* Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. The dataset is
* small, so this filters and sorts in memory rather than in SQL.
*/
export async function getRecentlyUpdatedRooms(
db: D1Database,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
const rooms = parseAll(results).filter((r) => isListable(r) && isPlayerMade(r))
const published = await lastPublishedAtByRoom(db)
const updatedAt = (r: Room): number => published.get(roomIdOf(r)) ?? createdAt(r)
rooms.sort((a, b) => updatedAt(b) - updatedAt(a) || roomIdOf(b) - roomIdOf(a))
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
TotalResults: rooms.length,
}
}
/**
* The "new" discovery row: listable, player-made rooms newest FIRST by creation time.
*
* Close to the browse screen's `tag=new` chip (see {@link NEW_TAG}) but not the same test:
* the chip drops Rec Room Originals, this drops the Coach account's rooms. Both mean
* "player-made" and agree on the data as it stands — the discovery rows deliberately all
* use ownership ({@link isPlayerMade}) so one row cannot include a room its sibling row
* excludes.
*
* Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. RoomId breaks
* ties, newest first, so rooms created in the same instant still page stably.
*/
export async function getNewRooms(
db: D1Database,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
const rooms = parseAll(results)
.filter((r) => isListable(r) && isPlayerMade(r))
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
TotalResults: rooms.length,
}
}
/** /**
* Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked * Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked
* by engagement (same score as the hot feed). Unlike the hot feed this returns a * by engagement (same score as the hot feed). Unlike the hot feed this returns a
@@ -1983,7 +2355,7 @@ export async function getRecommendedRooms(
return hydrateRooms( return hydrateRooms(
db, db,
parseAll(results) parseAll(results)
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true) .filter(isListable)
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b)) .sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
.slice(skip, skip + take), .slice(skip, skip + take),
stats stats
@@ -2017,9 +2389,7 @@ export interface FeaturedRoomGroup {
*/ */
export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> { export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> {
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>() const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
const rooms = parseAll(results).filter( const rooms = parseAll(results).filter(isListable)
(r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true
)
// FisherYates shuffle so the feed varies between requests. // FisherYates shuffle so the feed varies between requests.
for (let i = rooms.length - 1; i > 0; i--) { for (let i = rooms.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)) const j = Math.floor(Math.random() * (i + 1))
@@ -2067,7 +2437,10 @@ export async function getSimilarRooms(
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
const stats = await getRoomStats(db) const stats = await getRoomStats(db)
const scored = parseAll(results) // Ranking is by SHARED TAG COUNT, so every candidate needs its tags before the sort —
// not a filter one tag can narrow, since "shares any tag with the target" is the whole
// candidate set.
const scored = (await parseAllWithTags(db, results))
.filter( .filter(
(r) => (r) =>
roomIdOf(r) !== roomId && roomIdOf(r) !== roomId &&
@@ -2099,12 +2472,16 @@ export async function getSimilarRooms(
* array. Small dataset, so done in memory. * array. Small dataset, so done in memory.
*/ */
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> { export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>() // Selected in SQL off the tag index — a handful of rooms out of the whole table, so this
const base = new Set(['base']) // is the clearest case for narrowing before the blobs are read.
const { sql, binds } = roomsByTagsQuery([['base']])
const { results } = await db
.prepare(sql)
.bind(...binds)
.all<RoomRow>()
return hydrateRooms( return hydrateRooms(
db, db,
parseAll(results) parseAll(results)
.filter((r) => roomHasAnyTag(r, base))
.sort((a, b) => roomIdOf(a) - roomIdOf(b)) .sort((a, b) => roomIdOf(a) - roomIdOf(b))
.slice(skip, skip + take) .slice(skip, skip + take)
) )