mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] better room/player search
This commit is contained in:
@@ -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;
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
roomRoles,
|
||||
saveSubRoomData,
|
||||
searchRooms,
|
||||
searchRoomsByName,
|
||||
setPlayerData,
|
||||
setRoomDescription,
|
||||
setRoomImage,
|
||||
@@ -1208,6 +1209,9 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
|
||||
/** 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<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -1435,6 +1439,38 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user