diff --git a/apps/rooms/migrations/0023_room_invention.sql b/apps/rooms/migrations/0023_room_invention.sql new file mode 100644 index 0000000..439e0e6 --- /dev/null +++ b/apps/rooms/migrations/0023_room_invention.sql @@ -0,0 +1,20 @@ +-- Which inventions a room has in it — one row per (room, invention). +-- +-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync. +-- +-- A room save (`POST /rooms/:id/subrooms/:sid/data`) carries `InventionUsage`, a base64 +-- protobuf listing the inventions in the saved scene (see `decodeInventionUsage`). Each +-- save re-derives the room's rows here from every one of its subrooms, so the table is the +-- union across the room and an invention removed from it drops out on the next save. +-- +-- Empty until rooms are next saved: the `InventionUsage` already stored on subrooms is not +-- backfilled, since decoding it is not something SQL can do. + +CREATE TABLE IF NOT EXISTS room_invention ( + room_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + PRIMARY KEY (room_id, invention_id) +); + +-- The reverse lookup: which rooms use this invention. +CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id); diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 7901cc7..3249dcd 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -10,7 +10,9 @@ import { beforeAll, describe, expect, it } from 'vitest' import { AUDIT_LOG_SCHEMA_DDL, + countRoomsUsingInvention, createRoomInstance, + decodeInventionUsage, getRoomInstance, MessageType, NOTIFICATION_SCHEMA_DDL, @@ -22,6 +24,7 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +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' import importRooms from '../../../static/ImportRooms.json' @@ -119,6 +122,8 @@ beforeAll(async () => { // Vote-to-kick ballots (owned by `api`) and game invites (owned by `match`) — the cron // here sweeps the stale ones. 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 ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) @@ -3625,6 +3630,61 @@ describe('rooms endpoints', () => { } }) + it('a room save records which inventions the room uses in room_invention', async () => { + const save = async (inventionUsage: string) => { + const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify({ InventionUsage: inventionUsage, AutoPublish: true }), + }) + expect(((await res.json()) as { success: boolean }).success).toBe(true) + } + const rows = async (): Promise => + ( + await env.DB.prepare( + 'SELECT invention_id FROM room_invention WHERE room_id = 2 ORDER BY invention_id' + ).all<{ invention_id: number }>() + ).results.map((r) => r.invention_id) + + // The invention records the counts are written onto (the `api` worker's table). + for (const id of [257, 27941]) { + await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)') + .bind(JSON.stringify({ InventionId: id, NumPlayersHaveUsedInRoom: 0 })) + .run() + } + const usedIn = async (id: number): Promise => + ( + await env.DB.prepare( + "SELECT json_extract(data, '$.NumPlayersHaveUsedInRoom') AS n FROM invention WHERE id = ?1" + ) + .bind(id) + .first<{ n: number }>() + )?.n ?? -1 + + // A real client value: `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`. + expect(decodeInventionUsage('CAESBgil2gEQARIFCIECEAE=')).toEqual([27941, 257]) + await save('CAESBgil2gEQARIFCIECEAE=') + expect(await rows()).toEqual([257, 27941]) + expect(await countRoomsUsingInvention(env.DB, 27941)).toBe(1) + // The invention's own counter is the number of rooms it is in. + expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 1]) + + // Saving the same inventions again is not a second use. + await save('CAESBgil2gEQARIFCIECEAE=') + expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 1]) + + // An invention taken out of the room drops out, and its counter comes back down. + await save('CAESBQiBAhAB') + expect(await rows()).toEqual([257]) + expect(await countRoomsUsingInvention(env.DB, 27941)).toBe(0) + expect([await usedIn(257), await usedIn(27941)]).toEqual([1, 0]) + + // A value that doesn't decode records nothing and does not fail the save. + await save('not base64 !!') + expect(await rows()).toEqual([]) + expect(await usedIn(257)).toBe(0) + }) + it('a real client room-save body stages, and publish_save makes it live', async () => { const save = async (body: unknown) => SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, { diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index d5d461f..fd9be60 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -218,6 +218,17 @@ export const SUBROOM_SCHEMA_DDL: string[] = [ value TEXT NOT NULL, PRIMARY KEY (sub_room_id, permission, role) )`, + // Which inventions a room has in it (migrations/0023_room_invention.sql): one row per + // (room, invention), re-derived on every room save from the `InventionUsage` each of the + // room's subrooms last reported — see {@link syncRoomInventions}. The second index is + // the reverse lookup, "which rooms use this invention" — what an invention's + // `NumPlayersHaveUsedInRoom` is counted from. + `CREATE TABLE IF NOT EXISTS room_invention ( + room_id INTEGER NOT NULL, + invention_id INTEGER NOT NULL, + PRIMARY KEY (room_id, invention_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id)`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ @@ -1419,6 +1430,152 @@ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null { }) } +/** + * The invention ids inside a room save's `InventionUsage`, in the order it lists them. + * + * The value is a base64 PROTOBUF message, recovered by reading real saves — there is no + * schema, only field numbers: + * + * 1: varint — 1 on every save seen; a format version by the look of it + * 2: repeated message — one per invention in the scene + * 1: varint — the invention id + * 2: varint — 1 on every save seen; probably how many times it is placed + * + * `CAESBgil2gEQARIFCIECEAE=` is `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`. + * + * Only the ids are read. Anything that doesn't parse — not base64, a truncated message, a + * wire type this doesn't know — yields the ids found up to that point rather than + * throwing: this is bookkeeping riding on a room save, and a format the client changes + * must not be able to fail the save. + */ +export function decodeInventionUsage(inventionUsage: string): number[] { + let bytes: Uint8Array + try { + bytes = Uint8Array.from(atob(inventionUsage), (ch) => ch.charCodeAt(0)) + } catch { + return [] + } + + /** Walk one message's fields; false from `visit`, or anything malformed, stops the walk. */ + const walk = ( + buf: Uint8Array, + visit: (field: number, value: number | Uint8Array) => void + ): void => { + let at = 0 + const varint = (): number | null => { + let value = 0 + for (let shift = 0; at < buf.length; shift += 7) { + const byte = buf[at++] + // Multiplication, not `<<`: ids past 2^31 would wrap as a 32-bit shift. + value += (byte & 0x7f) * 2 ** shift + if ((byte & 0x80) === 0) return value + } + return null + } + while (at < buf.length) { + const key = varint() + if (key === null) return + const field = Math.floor(key / 8) + const wireType = key % 8 + if (wireType === 0) { + const value = varint() + if (value === null) return + visit(field, value) + } else if (wireType === 2) { + const length = varint() + if (length === null || at + length > buf.length) return + visit(field, buf.subarray(at, at + length)) + at += length + } else { + return + } + } + } + + const ids: number[] = [] + walk(bytes, (field, entry) => { + if (field !== 2 || typeof entry === 'number') return + walk(entry, (inner, value) => { + if (inner === 1 && typeof value === 'number' && Number.isSafeInteger(value)) ids.push(value) + }) + }) + return ids +} + +/** + * Re-derive a room's `room_invention` rows from its subrooms: the union of the inventions + * each subroom's last save reported in `InventionUsage`. An invention removed from the room + * drops out on the next save — and the set is derived from EVERY subroom rather than the + * one just saved, because the table is per room and a save of one subroom says nothing + * about what the others still hold. + * + * A subroom's `InventionUsage` is whatever its latest save sent, staged or published, so + * this is the room as its builders last left it, not strictly what players are loading. + * + * Every invention that entered or left the room then has its `NumPlayersHaveUsedInRoom` + * resynced — the number of ROOMS it is in, which is what this server has to put behind + * that field. Derived from the table, like `CheerCount` and `NumDownloads` from theirs, and + * a `json_set` on the one key of the `invention` record (the `api` worker's table, same + * database). Only the rows that changed are written, so the usual save — same inventions + * as last time — costs one read. + */ +export async function syncRoomInventions( + db: D1Database, + roomId: number, + room: Room +): Promise { + const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] + const now = new Set() + for (const sub of subRooms) { + if (typeof sub.InventionUsage !== 'string') continue + for (const id of decodeInventionUsage(sub.InventionUsage)) now.add(id) + } + + const { results } = await db + .prepare('SELECT invention_id FROM room_invention WHERE room_id = ?1') + .bind(roomId) + .all<{ invention_id: number }>() + const before = new Set(results.map((r) => r.invention_id)) + const added = [...now].filter((id) => !before.has(id)) + const removed = [...before].filter((id) => !now.has(id)) + if (added.length === 0 && removed.length === 0) return + + // One batch, in order: the membership changes land before the counts are re-derived. + await db.batch([ + ...removed.map((id) => + db + .prepare('DELETE FROM room_invention WHERE room_id = ?1 AND invention_id = ?2') + .bind(roomId, id) + ), + ...added.map((id) => + db + .prepare('INSERT OR IGNORE INTO room_invention (room_id, invention_id) VALUES (?1, ?2)') + .bind(roomId, id) + ), + ...[...added, ...removed].map((id) => + db + .prepare( + `UPDATE invention SET data = json_set(data, '$.NumPlayersHaveUsedInRoom', + (SELECT COUNT(*) FROM room_invention WHERE invention_id = ?1)) + WHERE id = ?1` + ) + .bind(id) + ), + ]) +} + +/** How many rooms an invention is used in — the stat `room_invention` exists to answer. */ +export async function countRoomsUsingInvention( + db: D1Database, + inventionId: number +): Promise { + const row = await db + .prepare('SELECT COUNT(*) AS n FROM room_invention WHERE invention_id = ?1') + .bind(inventionId) + .first<{ n: number }>() + return row?.n ?? 0 +} + /** * Persist a room-save against a specific subroom. Everything the save carries belongs to * that subroom's revision — nothing is written to the room. Returns the updated @@ -1521,6 +1678,9 @@ export async function saveSubRoomData( // Re-hydrate so the returned room reflects the just-saved subroom. await attachSubRooms(db, [room]) + // The one thing a save records OUTSIDE the subroom and its save row: which inventions + // the room now has in it. Its own table, so the room row is still not rewritten. + await syncRoomInventions(db, roomId, room) return { room, save } }