[sql] improvements

This commit is contained in:
Devin Zuczek
2026-10-25 13:35:18 -04:00
parent 8397997234
commit b02a646892
8 changed files with 182 additions and 108 deletions
+23 -4
View File
@@ -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<?)`) 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(
db: D1Database,
@@ -306,9 +323,11 @@ export async function searchAccounts(
if (q === '') return []
const { results } = await db
.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>()
return parseAll(results)
}
+2 -18
View File
@@ -2846,27 +2846,11 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
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<void> {
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