diff --git a/apps/api/migrations/0035_custom_avatar_item_badge_room.sql b/apps/api/migrations/0035_custom_avatar_item_badge_room.sql new file mode 100644 index 0000000..375cdd7 --- /dev/null +++ b/apps/api/migrations/0035_custom_avatar_item_badge_room.sql @@ -0,0 +1,18 @@ +-- Index the room-badge lookup that hydrates every room list (`attachBadges` in +-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single +-- room read — asks this table for the published badges of the rooms on the page, and without an +-- index that was a full scan of every custom avatar item (thousands of first-party rows) per +-- page. At the time of writing it was half of the database's row reads. +-- +-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses +-- a partial index only when the query's WHERE contains its term as written, and an expression +-- index only when the query names the same expression, so the query must keep BOTH +-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly — +-- change either and the index silently stops being used. The key is an expression rather than +-- a new generated column so the query reads the same whether or not this migration has run yet +-- (the `rooms` worker can deploy ahead of `api`'s migrations). +-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync. + +CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room + ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) + WHERE outfit_type = 1000; diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index f30e0df..cc84160 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -29,7 +29,7 @@ import { COACH_ACCOUNT_ID } from './custom-avatar-items-load' -/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */ +/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS custom_avatar_item ( data TEXT NOT NULL, @@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [ )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`, `CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`, + // The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room + // page; partial over the badge rows, keyed by the badge's room (migrations/0035). + `CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`, ] /** diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 5a6d473..54164ca 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -34,6 +34,7 @@ import { UNITY_ASSET_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 INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db' import { NotificationType } from '../../../../notify/src/notification-types' @@ -134,6 +135,7 @@ beforeAll(async () => { for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Inventions (owned by the api worker) — a room save writes each one's room count. for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves. for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -7209,3 +7211,39 @@ it('omits hydrated Badges when persisting a room settings edit', async () => { }>() expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges') }) + +// The badge lookup runs on every room page, served by a partial index over the badge rows +// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save +// hydrates; a draft stays on its creator's Studio shelf. +it('GET /rooms/:id hydrates the room\'s published badges', async () => { + await seedRoomWithSubRooms(env.DB, { + RoomId: 987671, + Name: 'BadgeHydration', + CreatorAccountId: 987671, + SubRooms: [], + }) + const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) => + JSON.stringify({ + CustomAvatarItemId: id, + CreatorAccountId: 987671, + Name: id, + OutfitType: 1000, + Accessibility: accessibility, + CurrentSaves: saves, + CustomBadgeMetadata: { RoomId: roomId }, + }) + const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)') + await env.DB.batch([ + insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])), + insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])), + insert.bind(badge('badge-unbuilt', 987671, 1, [])), + insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])), + ]) + // Twice: the second read takes the path that remembers the table exists. + for (let i = 0; i < 2; i++) { + const res = await SELF.fetch(`${ORIGIN}/rooms/987671`) + expect(res.status).toBe(200) + const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> } + expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published']) + } +}) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 6cc985e..32466d9 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -2846,17 +2846,31 @@ 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 - // API owns this table's migration; room deployments can precede that migration. - 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 + 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 } + // 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 + // goes back to scanning every custom avatar item on every room page. const rows = await db .prepare( "SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"