mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[sql] improvements
This commit is contained in:
@@ -347,6 +347,40 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Exact lookup by username — what the site's `/u/<name>` 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 ------------------------------------------------
|
// ---- Bulk / single lookup ------------------------------------------------
|
||||||
// Register the static `bulk` path before the `/account/:id` param route.
|
// Register the static `bulk` path before the `/account/:id` param route.
|
||||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||||
|
|||||||
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
|
|||||||
expect(await empty.json()).toEqual([])
|
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 () => {
|
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
||||||
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
||||||
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /account/bulk',
|
'GET /account/bulk',
|
||||||
'GET /account/me',
|
'GET /account/me',
|
||||||
'GET /account/search',
|
'GET /account/search',
|
||||||
|
'GET /account/username/{name}',
|
||||||
'GET /account/{id}',
|
'GET /account/{id}',
|
||||||
'GET /account/{id}/bio',
|
'GET /account/{id}/bio',
|
||||||
'GET /accountprivacysettings/{id}',
|
'GET /accountprivacysettings/{id}',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
validateIdentityAssertion,
|
validateIdentityAssertion,
|
||||||
} from '@repo/jwt'
|
} from '@repo/jwt'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
banFromReport,
|
banFromReport,
|
||||||
createReport,
|
createReport,
|
||||||
@@ -108,6 +109,9 @@ beforeAll(async () => {
|
|||||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
// 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.
|
// the token grant reads it for the evasion arms.
|
||||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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()
|
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} 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 { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
||||||
import {
|
import {
|
||||||
banFromReport,
|
banFromReport,
|
||||||
@@ -289,6 +290,9 @@ beforeAll(async () => {
|
|||||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
||||||
// accounts sharing a proven identity with the banned one.
|
// accounts sharing a proven identity with the banned one.
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+71
-73
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
|
|||||||
// The session token, the worker hostnames and the `call` every request goes through —
|
// 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.
|
// see api.ts for why they're a module of their own rather than defined here.
|
||||||
import {
|
import {
|
||||||
|
accountByUsername,
|
||||||
accountIdForUsername,
|
accountIdForUsername,
|
||||||
call,
|
call,
|
||||||
hasToken,
|
hasToken,
|
||||||
@@ -35,7 +36,7 @@ import { StatsPage } from './Stats'
|
|||||||
import { StudioAccessPage } from './StudioAccess'
|
import { StudioAccessPage } from './StudioAccess'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
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
|
* 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
|
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
|
* 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 —
|
* 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<PublicAccount[]> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
|
||||||
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
||||||
searchPlayers(username).then(
|
accountByUsername(username)
|
||||||
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The photos a player has taken. Sent WITH the session token when there is one: `api`
|
* 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. */
|
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
|
||||||
const PUMPKIN_FRAMES = [
|
const PUMPKIN_FRAMES = [
|
||||||
new URL('./pumple-frames/frame-00.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-01.png', import.meta.url).href,
|
||||||
new URL('./pumple-frames/frame-02.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-03.png', import.meta.url).href,
|
||||||
new URL('./pumple-frames/frame-04.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-05.png', import.meta.url).href,
|
||||||
new URL('./pumple-frames/frame-06.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-07.png', import.meta.url).href,
|
||||||
]
|
]
|
||||||
const PUMPKIN_W = 150
|
const PUMPKIN_W = 150
|
||||||
const PUMPKIN_H = 150
|
const PUMPKIN_H = 150
|
||||||
@@ -1887,57 +1877,65 @@ function LilPumpkin() {
|
|||||||
let speechTimer = 0
|
let speechTimer = 0
|
||||||
let last = performance.now()
|
let last = performance.now()
|
||||||
let raf = 0
|
let raf = 0
|
||||||
const effectNodes: HTMLElement[] = []
|
const effectNodes: HTMLElement[] = []
|
||||||
|
|
||||||
const spawnBatSwarm = () => {
|
const spawnBatSwarm = () => {
|
||||||
const flash = document.createElement('div')
|
const flash = document.createElement('div')
|
||||||
Object.assign(flash.style, {
|
Object.assign(flash.style, {
|
||||||
position: 'fixed', inset: '0',
|
position: 'fixed',
|
||||||
background: 'rgba(210, 0, 28, 0.32)',
|
inset: '0',
|
||||||
opacity: '1', transition: 'opacity 480ms ease-out',
|
background: 'rgba(210, 0, 28, 0.32)',
|
||||||
pointerEvents: 'none', zIndex: '100000',
|
opacity: '1',
|
||||||
})
|
transition: 'opacity 480ms ease-out',
|
||||||
document.body.appendChild(flash)
|
pointerEvents: 'none',
|
||||||
effectNodes.push(flash)
|
zIndex: '100000',
|
||||||
requestAnimationFrame(() => { flash.style.opacity = '0' })
|
})
|
||||||
window.setTimeout(() => {
|
document.body.appendChild(flash)
|
||||||
flash.remove()
|
effectNodes.push(flash)
|
||||||
const i = effectNodes.indexOf(flash)
|
requestAnimationFrame(() => {
|
||||||
if (i !== -1) effectNodes.splice(i, 1)
|
flash.style.opacity = '0'
|
||||||
}, 520)
|
})
|
||||||
|
window.setTimeout(() => {
|
||||||
|
flash.remove()
|
||||||
|
const i = effectNodes.indexOf(flash)
|
||||||
|
if (i !== -1) effectNodes.splice(i, 1)
|
||||||
|
}, 520)
|
||||||
|
|
||||||
const cx = x + PUMPKIN_W / 2
|
const cx = x + PUMPKIN_W / 2
|
||||||
const cy = y + jy + PUMPKIN_H / 2
|
const cy = y + jy + PUMPKIN_H / 2
|
||||||
for (let i = 0; i < 22; i++) {
|
for (let i = 0; i < 22; i++) {
|
||||||
const bat = document.createElement('span')
|
const bat = document.createElement('span')
|
||||||
const angle = Math.random() * Math.PI * 2
|
const angle = Math.random() * Math.PI * 2
|
||||||
const distance = 100 + Math.random() * 260
|
const distance = 100 + Math.random() * 260
|
||||||
const dx = Math.cos(angle) * distance
|
const dx = Math.cos(angle) * distance
|
||||||
const dy = Math.sin(angle) * distance
|
const dy = Math.sin(angle) * distance
|
||||||
Object.assign(bat.style, {
|
Object.assign(bat.style, {
|
||||||
position: 'fixed', left: `${cx}px`, top: `${cy}px`,
|
position: 'fixed',
|
||||||
fontSize: `${20 + Math.random() * 20}px`,
|
left: `${cx}px`,
|
||||||
lineHeight: '1', pointerEvents: 'none',
|
top: `${cy}px`,
|
||||||
zIndex: '100001', opacity: '1',
|
fontSize: `${20 + Math.random() * 20}px`,
|
||||||
transform: 'translate(-50%, -50%) scale(0.4)',
|
lineHeight: '1',
|
||||||
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
|
pointerEvents: 'none',
|
||||||
willChange: 'transform, opacity',
|
zIndex: '100001',
|
||||||
})
|
opacity: '1',
|
||||||
bat.textContent = '🦇'
|
transform: 'translate(-50%, -50%) scale(0.4)',
|
||||||
document.body.appendChild(bat)
|
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
|
||||||
effectNodes.push(bat)
|
willChange: 'transform, opacity',
|
||||||
requestAnimationFrame(() => {
|
})
|
||||||
bat.style.transform =
|
bat.textContent = '🦇'
|
||||||
`translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
|
document.body.appendChild(bat)
|
||||||
bat.style.opacity = '0'
|
effectNodes.push(bat)
|
||||||
})
|
requestAnimationFrame(() => {
|
||||||
window.setTimeout(() => {
|
bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
|
||||||
bat.remove()
|
bat.style.opacity = '0'
|
||||||
const i = effectNodes.indexOf(bat)
|
})
|
||||||
if (i !== -1) effectNodes.splice(i, 1)
|
window.setTimeout(() => {
|
||||||
}, 900)
|
bat.remove()
|
||||||
}
|
const i = effectNodes.indexOf(bat)
|
||||||
}
|
if (i !== -1) effectNodes.splice(i, 1)
|
||||||
|
}, 900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
root.style.transform = `translate(${x}px, ${y}px)`
|
root.style.transform = `translate(${x}px, ${y}px)`
|
||||||
|
|
||||||
@@ -2044,8 +2042,8 @@ function LilPumpkin() {
|
|||||||
walkDistance += step
|
walkDistance += step
|
||||||
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
|
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
|
||||||
if (nextFrame !== walkFrame) {
|
if (nextFrame !== walkFrame) {
|
||||||
walkFrame = nextFrame
|
walkFrame = nextFrame
|
||||||
sprite.src = PUMPKIN_FRAMES[walkFrame]
|
sprite.src = PUMPKIN_FRAMES[walkFrame]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
||||||
|
|||||||
+25
-13
@@ -219,23 +219,32 @@ export function useAction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an `@username` to the account id the workers address a player by.
|
* 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
|
||||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
* prefix search, which this once went through and filtered client-side; one profile view
|
||||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
* then cost a 20-row index walk, and the search bar's typeahead already keeps that query
|
||||||
* acting on whoever happened to sort first would be worse than refusing. The exact name
|
* busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
|
||||||
* always sorts first among its own prefixes, so it's inside the search limit whenever it
|
* is a real failure and throws.
|
||||||
* exists.
|
*/
|
||||||
|
export async function accountByUsername(input: string): Promise<PublicAccount | null> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<number> {
|
export async function accountIdForUsername(input: string): Promise<number> {
|
||||||
const name = input.trim().replace(/^@/, '')
|
const name = input.trim().replace(/^@/, '')
|
||||||
if (name === '') throw new Error('Enter a username.')
|
if (name === '') throw new Error('Enter a username.')
|
||||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
const found = await accountByUsername(name)
|
||||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
|
||||||
)
|
|
||||||
const found = Array.isArray(matches)
|
|
||||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
|
||||||
: undefined
|
|
||||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||||
return found.accountId
|
return found.accountId
|
||||||
}
|
}
|
||||||
@@ -245,6 +254,9 @@ export interface PublicAccount {
|
|||||||
accountId: number
|
accountId: number
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
profileImage: string
|
||||||
|
bannerImage: string
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -292,10 +292,27 @@ export const SEARCH_LIMIT = 20
|
|||||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
||||||
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
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
|
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
||||||
* alphabetically. Backed by the indexed `username_lower` generated column, so the
|
* alphabetically. Returns up to `limit` matches.
|
||||||
* `name%` LIKE stays index-friendly. 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<?)`) and
|
||||||
|
* walks the index in `ORDER BY` order, so the LIMIT stops it after `limit` rows. The
|
||||||
|
* LIKE stays as a residual filter on those rows so the match is exactly "begins with",
|
||||||
|
* with the user's `%`/`_` taken literally.
|
||||||
*/
|
*/
|
||||||
export async function searchAccounts(
|
export async function searchAccounts(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -306,9 +323,11 @@ export async function searchAccounts(
|
|||||||
if (q === '') return []
|
if (q === '') return []
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
|
`SELECT data FROM account
|
||||||
|
WHERE 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<AccountRow>()
|
.all<AccountRow>()
|
||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2846,27 +2846,11 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
|||||||
return rooms
|
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. */
|
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||||
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||||
if (!rooms.length) return
|
if (!rooms.length) return
|
||||||
if (!badgeTableSeen) {
|
// `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long
|
||||||
const exists = await db
|
// applied to the shared database; this no longer probes `sqlite_master` for it.
|
||||||
.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
|
|
||||||
}
|
|
||||||
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
|
// 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')`
|
// `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
|
// expression. SQLite matches both only as written: drop or rephrase either term and this
|
||||||
|
|||||||
Reference in New Issue
Block a user