[www] better room/player search

This commit is contained in:
Devin Zuczek
2026-10-26 19:31:20 -04:00
parent fc6cf31591
commit 44704fec51
10 changed files with 413 additions and 108 deletions
+2 -2
View File
@@ -308,7 +308,7 @@ export async function getAccountByUsername(
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
/**
* The smallest string that sorts after every string beginning with `prefix` under
@@ -316,7 +316,7 @@ const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
*/
const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
export const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
/**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
+40
View File
@@ -17,6 +17,7 @@
* database read-only and uses `getRoomById`. Each imports the subset it needs.
*/
import { escapeLike, prefixUpperBound } from './accounts-db'
import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
@@ -72,6 +73,13 @@ export const ROOM_SCHEMA_DDL: string[] = [
// feeds all order by it eventually; the WHERE clause is the point, not the key.
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
// The searchable rooms by lowercased name (migrations/0032_room_search_name.sql) — what
// {@link searchRoomsByName} seeks. PARTIAL over exactly {@link SEARCHABLE_WHERE}, so the
// walk never steps over a dorm or a private room: the rows it reads are the rows it
// serves. `idx_rooms_name_lower` can't do that — it holds every dorm, and a prefix seek
// on it checks each private room it passes against the blob.
`CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1`,
// 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
@@ -3691,6 +3699,38 @@ export async function searchRooms(
}
}
/**
* Searchable rooms whose name BEGINS with `query` (case-insensitive), alphabetically, at
* most `take` — the website's header search, which asks on every pause in typing.
*
* Not {@link searchRooms}: that is the game's search, a SUBSTRING match no index can
* serve, so it reads every searchable room per call. This is a range seek on
* `idx_room_search_name` (`>= q AND < q+U+10FFFF`, as {@link searchAccounts} does for
* usernames), walked in `ORDER BY` order so the LIMIT stops it: D1 bills `take` rows
* however many rooms exist. The WHERE repeats {@link SEARCHABLE_WHERE} verbatim because
* SQLite only uses a partial index whose condition the query states; the LIKE is a
* residual so `%`/`_` match literally. Rooms come back un-hydrated (no subrooms, tags or
* badges) — the caller draws a name, a picture and the counters.
*/
export async function searchRoomsByName(
db: D1Database,
query: string,
take: number
): Promise<Room[]> {
const q = query.trim().toLowerCase()
if (q === '' || take <= 0) return []
const { results } = await db
.prepare(
`SELECT ${ROOM_COLUMNS} FROM room
WHERE ${SEARCHABLE_WHERE}
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
ORDER BY name_lower LIMIT ?4`
)
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, take)
.all<RoomRow>()
return parseAll(results).filter(isSearchable)
}
/**
* Search suggestions for the box the player is typing in
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.