diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index b7fe185..5d8f397 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -9,6 +9,7 @@ import { getAccount, getAccountByUsername, getAccountsByIds, + SEARCH_LIMIT, searchAccounts, updateAccount, } from '@repo/domain' @@ -337,17 +338,28 @@ const app = new Hono() description: 'Username prefix; empty matches nothing meaningful', schema: { type: 'string' }, }, + { + name: 'take', + in: 'query', + required: false, + description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`, + schema: { type: 'integer' }, + }, ], responses: { 200: json(AccountDto.array(), 'Matching public accounts') }, }), async (c) => { const name = c.req.query('name') ?? '' - const accounts = await searchAccounts(c.env.DB, name) + // The seek stops at the LIMIT, so a caller that draws five (the site's header + // search) is billed five rows rather than the default twenty. + const take = Number.parseInt(c.req.query('take') ?? '', 10) + const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT)) + const accounts = await searchAccounts(c.env.DB, name, limit) return c.json(accounts.map(toAccountDto)) } ) - // Exact lookup by username — what the site's `/u/` profile pages and the staff + // Exact lookup by username — what the site's `/user/` profile pages and the staff // `@username` fields resolve a player through. An indexed equality on `username_lower`; // before this existed they went through the prefix search above and filtered the // exact hit client-side, which cost a 20-row index walk per profile view. Same path diff --git a/apps/rooms/migrations/0032_room_search_name.sql b/apps/rooms/migrations/0032_room_search_name.sql new file mode 100644 index 0000000..50a5fe5 --- /dev/null +++ b/apps/rooms/migrations/0032_room_search_name.sql @@ -0,0 +1,11 @@ +-- The searchable rooms by lowercased name, for the website's header search +-- (`GET /rooms/search/name`, searchRoomsByName). +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. +-- +-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve +-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing +-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed +-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill. +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; diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 6ca4188..c1cf9e9 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -70,6 +70,7 @@ import { roomRoles, saveSubRoomData, searchRooms, + searchRoomsByName, setPlayerData, setRoomDescription, setRoomImage, @@ -1208,6 +1209,9 @@ async function ownedRoomsExcludingDorm(c: Context) { /** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */ const DEFAULT_SUGGESTION_COUNT = 10 +/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */ +const NAME_SEARCH_MAX = 10 + const app = new Hono() .use( '*', @@ -1435,6 +1439,38 @@ const app = new Hono() } ) + // The website's header search: searchable rooms whose name STARTS with `query`, A–Z, + // as a bare array. Not the game's search (`/rooms/search` is a substring match that + // reads every searchable room); this is an index seek that reads only the rows it + // returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms. + .get( + '/rooms/search/name', + describeRoute({ + tags: ['Discovery'], + summary: 'Prefix-search rooms by name', + description: [ + 'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins', + 'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so', + 'it reads only the rooms it returns. Rooms are served without subrooms, tags or', + 'badges. The game’s substring search is `/rooms/search`.', + ].join(' '), + parameters: [ + stringQuery('query', 'Name prefix'), + intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`), + ], + responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') }, + }), + async (c) => { + const take = Number.parseInt(c.req.query('take') ?? '', 10) + const rooms = await searchRoomsByName( + c.env.DB, + c.req.query('query') ?? '', + Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX) + ) + return c.json(serveRooms(rooms)) + } + ) + // Type-ahead for the search box (`?query=r&take=4&searchSessionId=…`). A bare array of // plain STRINGS — suggestions, not rooms and not an envelope. // diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 62140b1..5ceeb0c 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -681,6 +681,46 @@ describe('rooms endpoints', () => { expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true) }) + it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => { + const search = async (query: string, extra = '') => + (await ( + await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`) + ).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }> + + // A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z. + const rec = await search('REC', '&take=10') + expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true) + for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true) + const names = rec.map((r) => r.Name.toLowerCase()) + expect(names).toEqual([...names].sort()) + expect(typeof rec[0]?.Stats.VisitCount).toBe('number') + + // A prefix, not a substring: "center" is inside RecCenter but doesn't start it. + expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false) + // `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing. + expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2) + expect(await search('', '&take=500')).toEqual([]) + expect(await search('%')).toEqual([]) + expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10) + // Dorms (named `@user's Dorm`) are never served. + expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true) + + // The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it + // returns. Fall back to a scan (or the unfiltered name index) and every call is billed + // for every room again. + const plan = await env.DB.prepare( + `EXPLAIN QUERY PLAN SELECT data FROM room + WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1 + AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\' + ORDER BY name_lower LIMIT 5` + ) + .bind('r', 'r\u{10FFFF}', 'r%') + .all<{ detail: string }>() + const detail = plan.results.map((r) => r.detail).join('\n') + expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name') + expect(detail).not.toContain('TEMP B-TREE') + }) + it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => { const suggest = async (query: string, extra = '') => (await ( @@ -6251,6 +6291,7 @@ describe('rooms endpoints', () => { 'GET /rooms/ownedby/{accountId}', 'GET /rooms/recommendations', 'GET /rooms/search', + 'GET /rooms/search/name', 'GET /rooms/visitedby/me', 'GET /rooms/visitedby/{playerId}', 'GET /rooms/{roomId}', diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 729c68c..232150d 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1129,11 +1129,11 @@ interface PublicRoom { Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number } } -/** Prefix-search accounts by username — backs the header search bar. */ -async function searchPlayers(query: string): Promise { +/** Prefix-search accounts by username — backs the header search. */ +async function searchPlayers(query: string, take: number): Promise { if (query.trim() === '') return [] return call( - `${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}` + `${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}` ) } @@ -1145,7 +1145,7 @@ const fetchPublicAccount = (username: string): Promise => * The photos a player has taken. Sent WITH the session token when there is one: `api` * reads it and, only when the list is the caller's own, includes the photos they keep * private — so a signed-in player sees all of theirs, on `/account` and on their own - * `/u/`, while everyone else's profile is the public list either way. Nothing + * `/user/`, while everyone else's profile is the public list either way. Nothing * here decides who the viewer is; the worker does, from the token. */ const fetchPlayerPhotos = (accountId: number): Promise => @@ -1218,91 +1218,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise` path, or null for any other path. */ +/** The `/user/` path (or the old `/u/`), or null for any other path. */ function usernameFromPath(path: string): string | null { - const match = /^\/u\/([^/]+)$/.exec(path) + const match = /^\/(?:user|u)\/([^/]+)$/.exec(path) return match ? decodeURIComponent(match[1]) : null } -// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only -// decides whether the search bar that leads to them is shown. -const SHOW_PLAYER_SEARCH: boolean = true +// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this +// only decides whether the search button that leads to them is shown. +const SHOW_SEARCH: boolean = true + +/** How many of each kind the search panel lists. */ +const SEARCH_TAKE = 5 /** - * The header search bar — a rec.net-style bubble dropdown of matching players as you - * type. Debounced so it doesn't fire a search per keystroke; closes on selecting a - * result, on Escape, or on clicking outside it. + * Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`: + * that one's substring match reads every public room per call, which a search fired on + * every pause in typing can't afford; this one is an index seek billed per row returned. */ -function PlayerSearch({ navigate }: { navigate: Navigate }) { - const [query, setQuery] = useState('') - const [results, setResults] = useState([]) +const searchRooms = (query: string, take: number): Promise => + query.trim() === '' + ? Promise.resolve([]) + : call( + `${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}` + ) + +function SearchIcon() { + return ( + + ) +} + +/** + * The header search: a magnifier button that opens a panel searching players and rooms + * together as you type. A button rather than an always-on bar so it fits a phone's nav. + * Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or + * on a tap outside it. Enter takes the first player, else the first room. + */ +function SiteSearch({ navigate }: { navigate: Navigate }) { const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [players, setPlayers] = useState([]) + const [rooms, setRooms] = useState([]) + // The query the results on screen answer, so "nothing found" waits for the answer. + const [answered, setAnswered] = useState('') const containerRef = useRef(null) + const inputRef = useRef(null) + // Answers by normalized query, for the panel's lifetime on the page: backspacing to a + // query already asked redraws it without another request (and another D1 read). + const cache = useRef(new Map()) useEffect(() => { - if (query.trim() === '') { - setResults([]) + const key = query.trim().toLowerCase() + const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => { + setPlayers(p.slice(0, SEARCH_TAKE)) + setRooms(r.slice(0, SEARCH_TAKE)) + setAnswered(query) + } + if (key === '') { + setPlayers([]) + setRooms([]) + setAnswered('') return } + const known = cache.current.get(key) + if (known) { + show(known) + return + } + let stale = false const timeout = setTimeout(() => { - void searchPlayers(query) - .then((r) => setResults(r.slice(0, 6))) - .catch(() => setResults([])) + void Promise.all([ + searchPlayers(key, SEARCH_TAKE).catch(() => null), + searchRooms(key, SEARCH_TAKE).catch(() => null), + ]).then(([p, r]) => { + const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []] + // A failed half isn't cached, so the next time this is typed it asks again. + if (p !== null && r !== null) cache.current.set(key, found) + if (!stale) show(found) + }) }, 250) - return () => clearTimeout(timeout) + return () => { + stale = true + clearTimeout(timeout) + } }, [query]) useEffect(() => { - const onClickOutside = (e: MouseEvent) => { + if (!open) return + inputRef.current?.focus() + const onPointerDown = (e: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false) } } - document.addEventListener('mousedown', onClickOutside) - return () => document.removeEventListener('mousedown', onClickOutside) - }, []) + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open]) - const go = (username: string) => { + const go = (to: string) => { setOpen(false) setQuery('') - navigate(`/u/${encodeURIComponent(username)}`) + navigate(to) } + const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}` + const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}` + const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0 return ( -
- { - setQuery(e.target.value) - setOpen(true) - }} - onFocus={() => setOpen(true)} - onKeyDown={(e) => { - if (e.key === 'Escape') setOpen(false) - if (e.key === 'Enter' && results[0]) go(results[0].username) - }} - /> - {open && results.length > 0 && ( -
- {results.map((r) => ( - - ))} +
+ + {open && ( +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false) + if (e.key === 'Enter') { + if (players[0]) go(playerPath(players[0])) + else if (rooms[0]) go(roomPath(rooms[0])) + } + }} + /> + {players.length > 0 && ( +
+

Players

+ {players.map((r) => ( + + ))} +
+ )} + {rooms.length > 0 && ( +
+

Rooms

+ {rooms.map((r) => ( + + ))} +
+ )} + {nothing &&

No players or rooms match.

}
)}
@@ -1429,7 +1537,7 @@ function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig | } /** - * A player's public profile page (`/u/`) — banner, avatar, bio-adjacent + * A player's public profile page (`/user/`) — banner, avatar, bio-adjacent * info, their public rooms, and their public photos. Read-only: this is the * "rec.net-style profile" other players browse to, not the owner's own dashboard * (that stays on `/account`). @@ -1451,7 +1559,7 @@ function PlayerPage({ useEffect(() => { // Waits for the config, not the session: this page is public, but every fetch on it // goes through `where()`, which has no hostnames until `/api/config` lands. On a cold - // load of `/u/` the effect would otherwise fire first and the page would be + // load of `/user/` the effect would otherwise fire first and the page would be // stuck on "Still starting up". if (config === undefined) return setAccount(undefined) @@ -2460,7 +2568,6 @@ function NavBar({ RecFlare ) @@ -3199,7 +3307,7 @@ function PublicRoomView({ {creator && (

by{' '} - + {creator.displayName || creator.username}

diff --git a/apps/www/src/client/Item.tsx b/apps/www/src/client/Item.tsx index eb9fdac..06cb448 100644 --- a/apps/www/src/client/Item.tsx +++ b/apps/www/src/client/Item.tsx @@ -216,10 +216,10 @@ export function ItemPage({

by{' '} { e.preventDefault() - navigate(`/u/${encodeURIComponent(creator.username)}`) + navigate(`/user/${encodeURIComponent(creator.username)}`) }} > {creator.displayName || creator.username} @@ -379,10 +379,10 @@ export function InventionPage({

by{' '} { e.preventDefault() - navigate(`/u/${encodeURIComponent(creator.username)}`) + navigate(`/user/${encodeURIComponent(creator.username)}`) }} > {creator.displayName || creator.username} diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx index 4443db1..542db04 100644 --- a/apps/www/src/client/Moderation.tsx +++ b/apps/www/src/client/Moderation.tsx @@ -369,7 +369,7 @@ function BanSummary({ } /** - * One line for a player's profile page (`/u/`): whether a ban blocks them right + * One line for a player's profile page (`/user/`): whether a ban blocks them right * now — their own or a linked account's — and a link to their full history here. */ export function PlayerBanStatus({ diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 17dd36e..3d5811e 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -135,8 +135,8 @@ body { font-size: 0.95rem; color: var(--muted); text-decoration: none; - /* A link is one or two words; with the search bar in the row it has to give up space - by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */ + /* A link is one or two words; short of room the ROW wraps (see the nav rule under + site search), never a link mid-word. */ white-space: nowrap; } @@ -1797,50 +1797,101 @@ button[type='submit']:disabled { } } -/* ---- Player search ------------------------------------------------------ */ +/* ---- Site search ------------------------------------------------------- */ -.player-search { +/* The panel hangs off the NAV, not the button: the nav can wrap on a phone, which moves + the button, but the nav's right edge stays put — so the panel always opens flush right + and never runs off the screen. */ +.nav { position: relative; - width: min(280px, 100%); + /* Wraps only when the row doesn't fit (a staff account's extra links on a phone). */ + flex-wrap: wrap; } -.player-search input { - margin: 0; - height: 40px; - padding: 8px 12px; +.site-search-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + border: 1px solid var(--line); border-radius: 999px; background: var(--surface); - border-color: var(--line); + color: var(--muted); + cursor: pointer; transition: + color 0.15s ease, border-color 0.15s ease, - background 0.15s ease, - box-shadow 0.15s ease; + background 0.15s ease; } -.player-search input:focus { +.site-search-toggle:hover, +.site-search-toggle.active { + color: var(--text); border-color: var(--accent); background: var(--surface-hi); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); - outline: none; } -.player-search-bubble { +.site-search-panel { position: absolute; z-index: 20; - top: calc(100% + 8px); - left: 0; - right: 0; - padding: 6px; + top: calc(100% - 8px); + right: 20px; + width: min(380px, calc(100% - 40px)); + max-height: min(70vh, 560px); + overflow-y: auto; + padding: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 30px rgb(0 0 0 / 28%); } -.player-search-result { +.site-search-panel input { + margin: 0; + height: 42px; + padding: 8px 14px; + border-radius: 999px; + background: var(--surface-hi); + border-color: var(--line); + /* 16px keeps iOS from zooming the page when the box takes focus. */ + font-size: 16px; +} + +.site-search-panel input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); + outline: none; +} + +.site-search-group { + margin-top: 8px; +} + +.site-search-heading, +.site-search-empty { + margin: 6px 8px 4px; + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.site-search-empty { + margin: 12px 8px 6px; + font-size: 0.9rem; + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +.site-search-result { display: flex; align-items: center; width: 100%; + min-width: 0; gap: 10px; padding: 8px; border: 0; @@ -1852,22 +1903,35 @@ button[type='submit']:disabled { font-family: var(--body); } -.player-search-result:hover, -.player-search-result:focus-visible { +.site-search-result > span { + min-width: 0; +} + +.site-search-result:hover, +.site-search-result:focus-visible { background: var(--surface-hi); } -.player-search-avatar { +.site-search-avatar, +.site-search-thumb { width: 36px; height: 36px; flex: none; object-fit: cover; - border-radius: 50%; border: 1px solid var(--line); background: var(--surface-hi); } -.player-search-name { +.site-search-avatar { + border-radius: 50%; +} + +.site-search-thumb { + width: 54px; + border-radius: 6px; +} + +.site-search-name { display: block; overflow: hidden; font-weight: 600; @@ -1876,7 +1940,7 @@ button[type='submit']:disabled { white-space: nowrap; } -.player-search-handle { +.site-search-sub { display: block; margin-top: 2px; color: var(--muted); @@ -1887,13 +1951,6 @@ button[type='submit']:disabled { white-space: nowrap; } -/* The search bar is a third thing in a row built for two, so the nav wraps when it runs - out of room rather than overflowing. No breakpoint: wrapping only happens when the row - doesn't fit, so wide screens are untouched. */ -.nav { - flex-wrap: wrap; -} - /* The account page's banner upload, in the profile banner's shape so what's uploaded looks as it will across the top of the profile. */ .banner-upload { diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index b81f05f..346a6fd 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -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 diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index ad05dd2..a03e449 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -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 { + 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() + 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.