From b02a646892fe9a54d6e1b2414b71261243b894c8 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 25 Oct 2026 13:35:18 -0400 Subject: [PATCH] [sql] improvements --- apps/accounts/src/accounts.app.ts | 34 +++++ .../accounts/src/test/integration/api.test.ts | 19 +++ apps/auth/src/test/integration/api.test.ts | 4 + apps/match/src/test/integration/api.test.ts | 4 + apps/www/src/client/App.tsx | 144 +++++++++--------- apps/www/src/client/api.ts | 38 +++-- packages/domain/src/accounts-db.ts | 27 +++- packages/domain/src/rooms-db.ts | 20 +-- 8 files changed, 182 insertions(+), 108 deletions(-) diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index cf2727e..b7fe185 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -347,6 +347,40 @@ const app = new Hono() } ) + // Exact lookup by username — what the site's `/u/` 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 + // as the official API's `GET /account/username/{username}`. + .get( + '/account/username/:name', + describeRoute({ + tags: ['Lookup'], + summary: 'A single public account, by exact username', + description: + 'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.', + parameters: [ + { + name: 'name', + in: 'path', + required: true, + description: 'The username, exactly (case-insensitive)', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(AccountDto, 'The public account'), + 404: { description: 'No account has that username' }, + }, + }), + async (c) => { + const name = c.req.param('name').trim().replace(/^@/, '') + const account = name === '' ? null : await getAccountByUsername(c.env.DB, name) + if (!account) return c.json({ error: `There's no player called @${name}.` }, 404) + return c.json(toAccountDto(account)) + } + ) + // ---- Bulk / single lookup ------------------------------------------------ // Register the static `bulk` path before the `/account/:id` param route. .get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 50b8fca..726688a 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -153,6 +153,24 @@ describe('public endpoints', () => { expect(await empty.json()).toEqual([]) }) + test('GET /account/username/:name is an exact, case-insensitive lookup', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`) + expect(res.status).toBe(200) + const account = (await res.json()) as { accountId: number; username: string } + expect(account).toMatchObject({ accountId: 1, username: 'Coach' }) + // A leading `@` is tolerated — staff fields take names in that form. + const at = await exports.default.fetch( + `${ORIGIN}/account/username/${encodeURIComponent('@Coach')}` + ) + expect(((await at.json()) as { accountId: number }).accountId).toBe(1) + // A prefix is NOT a match: that's what `/account/search` is for. + const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`) + expect(prefix.status).toBe(404) + const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`) + expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." }) + }) + test('GET /account/:id/bio returns an empty bio', async () => { const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`) expect(await res.json()).toEqual({ accountId: 7, bio: '' }) @@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => { 'GET /account/bulk', 'GET /account/me', 'GET /account/search', + 'GET /account/username/{name}', 'GET /account/{id}', 'GET /account/{id}/bio', 'GET /accountprivacysettings/{id}', diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 8976111..af3a617 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -24,6 +24,7 @@ import { validateIdentityAssertion, } from '@repo/jwt' +import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db' import { banFromReport, createReport, @@ -108,6 +109,9 @@ beforeAll(async () => { // Report table (owned by the api worker) — a ban is a report row with `banned` set; // the token grant reads it for the evasion arms. for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Custom avatar items (owned by the api worker) — every room read joins the published + // room badges from this table. + for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 51aac5c..9a3ed42 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -26,6 +26,7 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db' import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db' import { banFromReport, @@ -289,6 +290,9 @@ beforeAll(async () => { // Platform identity links (owned by the auth worker) — a ban also reaches the // accounts sharing a proven identity with the banned one. for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Custom avatar items (owned by the api worker) — every room read joins the published + // room badges from this table. + for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) /** diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index eafe7bb..09ac34e 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter' // The session token, the worker hostnames and the `call` every request goes through — // see api.ts for why they're a module of their own rather than defined here. import { + accountByUsername, accountIdForUsername, call, hasToken, @@ -35,7 +36,7 @@ import { StatsPage } from './Stats' import { StudioAccessPage } from './StudioAccess' import type { ReactNode } from 'react' -import type { Hosts } from './api' +import type { Hosts, PublicAccount } from './api' /** * Site config from `www`. `signupEnabled` is false when the operator has no Turnstile @@ -990,16 +991,6 @@ function roomIdFromPath(path: string): number | null { return match ? Number.parseInt(match[1], 10) : null } -/** Public account shape, from `GET /account/:id` or `/account/search`. */ -interface PublicAccount { - accountId: number - username: string - displayName: string - profileImage: string - bannerImage: string - createdAt: string -} - /** * A player's photo, from the `ImagesPlayer` projection. Public unless the list was the * viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here — @@ -1029,10 +1020,9 @@ async function searchPlayers(query: string): Promise { ) } +/** The profile page's account: an exact lookup, not the search bar's prefix query. */ const fetchPublicAccount = (username: string): Promise => - searchPlayers(username).then( - (matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null - ) + accountByUsername(username) /** * The photos a player has taken. Sent WITH the session token when there is one: `api` @@ -1816,14 +1806,14 @@ function halloweenActive(): boolean { /** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */ const PUMPKIN_FRAMES = [ - new URL('./pumple-frames/frame-00.png', import.meta.url).href, - new URL('./pumple-frames/frame-01.png', import.meta.url).href, - new URL('./pumple-frames/frame-02.png', import.meta.url).href, - new URL('./pumple-frames/frame-03.png', import.meta.url).href, - new URL('./pumple-frames/frame-04.png', import.meta.url).href, - new URL('./pumple-frames/frame-05.png', import.meta.url).href, - new URL('./pumple-frames/frame-06.png', import.meta.url).href, - new URL('./pumple-frames/frame-07.png', import.meta.url).href, + new URL('./pumple-frames/frame-00.png', import.meta.url).href, + new URL('./pumple-frames/frame-01.png', import.meta.url).href, + new URL('./pumple-frames/frame-02.png', import.meta.url).href, + new URL('./pumple-frames/frame-03.png', import.meta.url).href, + new URL('./pumple-frames/frame-04.png', import.meta.url).href, + new URL('./pumple-frames/frame-05.png', import.meta.url).href, + new URL('./pumple-frames/frame-06.png', import.meta.url).href, + new URL('./pumple-frames/frame-07.png', import.meta.url).href, ] const PUMPKIN_W = 150 const PUMPKIN_H = 150 @@ -1887,57 +1877,65 @@ function LilPumpkin() { let speechTimer = 0 let last = performance.now() let raf = 0 - const effectNodes: HTMLElement[] = [] + const effectNodes: HTMLElement[] = [] - const spawnBatSwarm = () => { - const flash = document.createElement('div') - Object.assign(flash.style, { - position: 'fixed', inset: '0', - background: 'rgba(210, 0, 28, 0.32)', - opacity: '1', transition: 'opacity 480ms ease-out', - pointerEvents: 'none', zIndex: '100000', - }) - document.body.appendChild(flash) - effectNodes.push(flash) - requestAnimationFrame(() => { flash.style.opacity = '0' }) - window.setTimeout(() => { - flash.remove() - const i = effectNodes.indexOf(flash) - if (i !== -1) effectNodes.splice(i, 1) - }, 520) + const spawnBatSwarm = () => { + const flash = document.createElement('div') + Object.assign(flash.style, { + position: 'fixed', + inset: '0', + background: 'rgba(210, 0, 28, 0.32)', + opacity: '1', + transition: 'opacity 480ms ease-out', + pointerEvents: 'none', + zIndex: '100000', + }) + document.body.appendChild(flash) + effectNodes.push(flash) + requestAnimationFrame(() => { + flash.style.opacity = '0' + }) + window.setTimeout(() => { + flash.remove() + const i = effectNodes.indexOf(flash) + if (i !== -1) effectNodes.splice(i, 1) + }, 520) - const cx = x + PUMPKIN_W / 2 - const cy = y + jy + PUMPKIN_H / 2 - for (let i = 0; i < 22; i++) { - const bat = document.createElement('span') - const angle = Math.random() * Math.PI * 2 - const distance = 100 + Math.random() * 260 - const dx = Math.cos(angle) * distance - const dy = Math.sin(angle) * distance - Object.assign(bat.style, { - position: 'fixed', left: `${cx}px`, top: `${cy}px`, - fontSize: `${20 + Math.random() * 20}px`, - lineHeight: '1', pointerEvents: 'none', - zIndex: '100001', opacity: '1', - transform: 'translate(-50%, -50%) scale(0.4)', - transition: 'transform 850ms ease-out, opacity 850ms ease-out', - willChange: 'transform, opacity', - }) - bat.textContent = '🦇' - document.body.appendChild(bat) - effectNodes.push(bat) - requestAnimationFrame(() => { - bat.style.transform = - `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)` - bat.style.opacity = '0' - }) - window.setTimeout(() => { - bat.remove() - const i = effectNodes.indexOf(bat) - if (i !== -1) effectNodes.splice(i, 1) - }, 900) - } - } + const cx = x + PUMPKIN_W / 2 + const cy = y + jy + PUMPKIN_H / 2 + for (let i = 0; i < 22; i++) { + const bat = document.createElement('span') + const angle = Math.random() * Math.PI * 2 + const distance = 100 + Math.random() * 260 + const dx = Math.cos(angle) * distance + const dy = Math.sin(angle) * distance + Object.assign(bat.style, { + position: 'fixed', + left: `${cx}px`, + top: `${cy}px`, + fontSize: `${20 + Math.random() * 20}px`, + lineHeight: '1', + pointerEvents: 'none', + zIndex: '100001', + opacity: '1', + transform: 'translate(-50%, -50%) scale(0.4)', + transition: 'transform 850ms ease-out, opacity 850ms ease-out', + willChange: 'transform, opacity', + }) + bat.textContent = '🦇' + document.body.appendChild(bat) + effectNodes.push(bat) + requestAnimationFrame(() => { + bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)` + bat.style.opacity = '0' + }) + window.setTimeout(() => { + bat.remove() + const i = effectNodes.indexOf(bat) + if (i !== -1) effectNodes.splice(i, 1) + }, 900) + } + } root.style.transform = `translate(${x}px, ${y}px)` @@ -2044,8 +2042,8 @@ function LilPumpkin() { walkDistance += step const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length if (nextFrame !== walkFrame) { - walkFrame = nextFrame - sprite.src = PUMPKIN_FRAMES[walkFrame] + walkFrame = nextFrame + sprite.src = PUMPKIN_FRAMES[walkFrame] } if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1 diff --git a/apps/www/src/client/api.ts b/apps/www/src/client/api.ts index 2d84fa7..31cc451 100644 --- a/apps/www/src/client/api.ts +++ b/apps/www/src/client/api.ts @@ -219,23 +219,32 @@ export function useAction() { } /** - * Resolve an `@username` to the account id the workers address a player by. - * - * `accounts` serves no exact-name lookup, so this goes through the PREFIX search and - * keeps only an exact (case-insensitive) hit: a prefix match is a different player, and - * acting on whoever happened to sort first would be worse than refusing. The exact name - * always sorts first among its own prefixes, so it's inside the search limit whenever it - * exists. + * The public account behind an exact `@username`, or null when nobody has the name. + * `accounts`' `GET /account/username/:name` is an indexed equality lookup — NOT the + * prefix search, which this once went through and filtered client-side; one profile view + * then cost a 20-row index walk, and the search bar's typeahead already keeps that query + * busy enough. A 404 is the one non-ok answer that means "no such player"; anything else + * is a real failure and throws. + */ +export async function accountByUsername(input: string): Promise { + const name = input.trim().replace(/^@/, '') + if (name === '') return null + const res = await fetch(`${where().accounts}/account/username/${encodeURIComponent(name)}`) + if (res.status === 404) return null + const data = (await res.json().catch(() => ({}))) as Record + if (!res.ok) throw new Error(errorMessage(data, res.status)) + return data as unknown as PublicAccount +} + +/** + * Resolve an `@username` to the account id the workers address a player by. An exact + * match only: a prefix match is a different player, and acting on whoever happened to + * sort first would be worse than refusing. */ export async function accountIdForUsername(input: string): Promise { const name = input.trim().replace(/^@/, '') if (name === '') throw new Error('Enter a username.') - const matches = await call>( - `${where().accounts}/account/search?name=${encodeURIComponent(name)}` - ) - const found = Array.isArray(matches) - ? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase()) - : undefined + const found = await accountByUsername(name) if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`) return found.accountId } @@ -245,6 +254,9 @@ export interface PublicAccount { accountId: number username: string displayName: string + profileImage: string + bannerImage: string + createdAt: string } /** diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 16fb9c6..c5f11c7 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -292,10 +292,27 @@ 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, '\\$&') +/** + * The smallest string that sorts after every string beginning with `prefix` under + * SQLite's default BINARY (byte-wise) collation: the prefix followed by U+10FFFF, whose + * 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}` + /** * Prefix-search accounts by username (case-insensitive, "begins with"), ordered - * alphabetically. Backed by the indexed `username_lower` generated column, so the - * `name%` LIKE stays index-friendly. Returns up to `limit` matches. + * alphabetically. Returns up to `limit` matches. + * + * The prefix is applied as a RANGE on the indexed `username_lower` generated column + * (`>= name AND < name+U+10FFFF`), not as `LIKE 'name%'`. SQLite only turns a LIKE into + * an index seek when the LIKE is case-sensitive or the index is NOCASE, and never when + * it carries an ESCAPE clause, so the LIKE form was a full index scan on every call — + * 48k rows read per keystroke in the site's search bar, tens of millions a day on D1's + * meter. The range is a seek (`SEARCH … (username_lower>? AND username_lower= ?1 AND username_lower < ?2 AND username_lower LIKE ?3 ESCAPE '\\' + ORDER BY username_lower LIMIT ?4` ) - .bind(`${escapeLike(q)}%`, limit) + .bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, limit) .all() return parseAll(results) } diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 32466d9..ed04c6b 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -2846,27 +2846,11 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise { return rooms } -/** - * Whether `custom_avatar_item` has been seen on this database. API owns that table's migration - * and room deployments can precede it, so {@link attachBadges} probes for it — but a table - * never goes away once created, so a hit is remembered and the probe (a scan of - * `sqlite_master`, on every room page) runs only until the first hit. - */ -let badgeTableSeen = false - /** Published room-linked image badges; drafts remain on the creator's Studio shelf. */ async function attachBadges(db: D1Database, rooms: Room[]): Promise { if (!rooms.length) return - if (!badgeTableSeen) { - const exists = await db - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'") - .first() - if (!exists) { - for (const room of rooms) room.Badges = [] - return - } - badgeTableSeen = true - } + // `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long + // applied to the shared database; this no longer probes `sqlite_master` for it. // Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over // `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')` // expression. SQLite matches both only as written: drop or rephrase either term and this