diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b541a7b..28ecb07 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2,7 +2,7 @@ import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' -import { GAME_VERSION } from '@repo/domain' +import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain' import '../../api.app' @@ -52,8 +52,10 @@ beforeAll(async () => { creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL )` ).run() - const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)') - await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r)))) + // Subrooms live in their own table now; getRoomById hydrates from it, so create it and + // split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration). + for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record) // Accounts table (matching the auth worker's migration) — uploadsaved records // profile thumbnails on the account row. Seed the account the test token (sub diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 339aa54..42d54a3 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -11,6 +11,7 @@ import { getAccountByUsername, getAccountsByPlatformId, getPasswordHash, + getRoomById, hashPassword, RoomInstanceType, setLastLoginTime, @@ -101,12 +102,11 @@ async function placeNewPlayerInOrientation( accountId: number, deviceClass: number ): Promise { - const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1') - .bind(ORIENTATION_ROOM_ID) - .first<{ data: string }>() - if (!row) return + // getRoomById hydrates the room's SubRooms from the subroom table (they no longer + // live in the room blob), so the Orientation scene resolves the same way match does. + const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID) + if (!room) return - const room = JSON.parse(row.data) as Record const subRooms = room.SubRooms const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as Record | undefined diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 5c43fa1..75c8992 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -4,7 +4,14 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' -import { getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' +import { + getAccountsByDeviceId, + hashPassword, + PRESENCE_SCHEMA_DDL, + SCHEMA_DDL, + seedRoomWithSubRooms, + SUBROOM_SCHEMA_DDL, +} from '@repo/domain' import { isLinkedToPlatformIdentity } from '../../auth.app' import { REFRESH_SCHEMA_DDL } from '../../refresh-db' @@ -48,16 +55,14 @@ beforeAll(async () => { room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL )` ).run() - await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)') - .bind( - JSON.stringify({ - RoomId: 13, - Name: 'Orientation', - IsDorm: false, - SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }], - }) - ) - .run() + // Subrooms live in their own table; seed the Orientation room and split its subroom into it. + for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + await seedRoomWithSubRooms(env.DB, { + RoomId: 13, + Name: 'Orientation', + IsDorm: false, + SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }], + }) }) /** Decode a JWT payload (no verification) for asserting claims. */ diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 2fa0bbb..1ae365a 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -15,6 +15,8 @@ import { getRoomInstance, PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL, + seedRoomWithSubRooms, + SUBROOM_SCHEMA_DDL, } from '@repo/domain' import { scheduled } from '../../match.app' @@ -90,8 +92,9 @@ beforeAll(async () => { is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL )` ).run() - const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)') - await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r)))) + // Subrooms live in their own table now; seed each room and split its subrooms into it. + for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record) // Room instances (owned by the rooms worker) — matchmaking finds/creates here. for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat. @@ -508,6 +511,26 @@ describe('auth-gated endpoints', () => { }) }) + test('each player’s dorm gets a distinct global subroom id', async () => { + // Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1. + // With subrooms minted from the global sequence, each dorm gets its own unique id. + const dormSubRoomId = async (sub: string): Promise => { + const body = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { + method: 'POST', + headers: await bearer(sub), + }) + ).json()) as { roomInstance: { subRoomId: number } } + return body.roomInstance.subRoomId + } + const a = await dormSubRoomId('7001') + const b = await dormSubRoomId('7002') + expect(a).not.toBe(b) + // Neither reuses the seed dorm template's SubRoomId (1). + expect(a).not.toBe(1) + expect(b).not.toBe(1) + }) + test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, { method: 'POST', diff --git a/apps/rooms/migrations/0007_subrooms.sql b/apps/rooms/migrations/0007_subrooms.sql new file mode 100644 index 0000000..0d790c8 --- /dev/null +++ b/apps/rooms/migrations/0007_subrooms.sql @@ -0,0 +1,55 @@ +-- Subrooms as first-class entities. In the original game a subroom has its own +-- globally-unique, autoincrementing `SubRoomId` (minted from a single sequence, not +-- per-room), so they can't live inside the room JSON blob — cloning/dorm creation +-- would otherwise reuse ids and collide across rooms. This moves them into their own +-- `subroom` table keyed by an AUTOINCREMENT `sub_room_id`, backfills from each room's +-- embedded `SubRooms`, and drops `SubRooms` from the room blob. Rooms re-embed their +-- `SubRooms` array on read. Generated from packages/domain/src/rooms-db.ts +-- (SUBROOM_SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS subroom ( + sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + data TEXT NOT NULL + ); +CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id); + +-- Backfill. The live blob already contains collisions (the bug this migration fixes: +-- every dorm carries SubRoomId 1, clones reuse per-room ids), so we can't preserve all +-- ids. We keep the FIRST occurrence of each distinct SubRoomId at its original id (so the +-- seeded rooms keep their canonical ids) and mint fresh globally-unique ids for the rest. + +-- 1) First occurrence of each non-null SubRoomId keeps its id (lowest room_id wins). +INSERT INTO subroom (sub_room_id, room_id, data) + SELECT sid, room_id, data FROM ( + SELECT + CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid, + r.room_id AS room_id, + je.value AS data, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(je.value, '$.SubRoomId') + ORDER BY r.room_id + ) AS rn + FROM room r, json_each(r.data, '$.SubRooms') je + ) + WHERE rn = 1 AND sid IS NOT NULL; + +-- 2) Everything else (the duplicates, and any null ids) gets a fresh autoincrement id. +-- Inserting the explicit ids above advanced sqlite_sequence, so these continue past +-- the highest kept id and never collide. +INSERT INTO subroom (room_id, data) + SELECT room_id, data FROM ( + SELECT + CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid, + r.room_id AS room_id, + je.value AS data, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(je.value, '$.SubRoomId') + ORDER BY r.room_id + ) AS rn + FROM room r, json_each(r.data, '$.SubRooms') je + ) + WHERE NOT (rn = 1 AND sid IS NOT NULL); + +-- Single source of truth: drop the now-migrated SubRooms array from the room blob. +UPDATE room SET data = json_remove(data, '$.SubRooms'); diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index ba800a1..5264ec8 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -9,6 +9,8 @@ import { PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, + seedRoomWithSubRooms, + SUBROOM_SCHEMA_DDL, } from '@repo/domain' import importRooms from '../../../static/ImportRooms.json' @@ -50,11 +52,12 @@ beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence table (read by the photon access-token handler). for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() - const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)') - await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r)))) + // 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) }) describe('rooms endpoints', () => { @@ -1358,4 +1361,28 @@ describe('rooms endpoints', () => { ).json()) as { SubRoomId: number } expect(fetched.SubRoomId).toBe(body.value?.SubRoomId) }) + + it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => { + // The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone — + // colliding with another room that already owns subroom 3. The subroom table's + // autoincrement mints an id above every existing subroom instead. + const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{ + m: number + }>())!.m + + const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, { + method: 'POST', + headers: await bearer('1'), + }) + const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } } + // Above every prior subroom id — a fresh global id, not a per-room collision. + expect(body.value.SubRoomId).toBeGreaterThan(maxBefore) + expect(body.value.RoomId).toBe(2) + + // The id is unique across the whole table (exactly one row owns it). + const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1') + .bind(body.value.SubRoomId) + .first<{ n: number }>())!.n + expect(dupes).toBe(1) + }) }) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 3d50710..488797c 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -44,6 +44,24 @@ export const ROOM_SCHEMA_DDL: string[] = [ )`, ] +/** + * Subroom schema DDL (mirror of migrations/0007_subrooms.sql). Subrooms are + * first-class entities with their own globally-unique, autoincrementing id — the + * original game mints `SubRoomId` from a single sequence, not per-room — so they + * live in their own table rather than inside the room JSON blob. `data` holds the + * rest of the subroom's client shape; `sub_room_id`/`room_id` are the authoritative + * columns (re-injected over `data` on read). Rooms re-embed their `SubRooms` array + * on read (see {@link getRoomById}); nothing persists SubRooms back into the room blob. + */ +export const SUBROOM_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS subroom ( + sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + data TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`, +] + /** A stored room — the parsed JSON blob (full client-facing room response). */ export type Room = Record @@ -120,7 +138,15 @@ export async function cloneRoom( CreatedAt: new Date().toISOString(), } - await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(cloned)).run() + // serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are + // inserted into the subroom table below with fresh globally-unique ids. + await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run() + const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : [] + const clonedSubRooms: SubRoom[] = [] + for (const sub of sourceSubRooms) { + clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId })) + } + cloned.SubRooms = clonedSubRooms return cloned } @@ -172,7 +198,7 @@ export async function updateRoomFields( const updated: Room = { ...room, ...patch } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(updated)) + .bind(roomId, serializeRoom(updated)) .run() return updated } @@ -207,7 +233,7 @@ export async function setRoomRole( const updated: Room = { ...room, Roles: roles } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(updated)) + .bind(roomId, serializeRoom(updated)) .run() return updated } @@ -249,16 +275,14 @@ export async function toggleRoomTag( const updated: Room = { ...room, Tags: nextTags } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(updated)) + .bind(roomId, serializeRoom(updated)) .run() return updated } -/** Find a subroom (by SubRoomId) inside a room's `SubRooms` array, or undefined. */ -export function findSubRoom(room: Room, subRoomId: number): Record | undefined { - const subRooms = Array.isArray(room.SubRooms) - ? (room.SubRooms as Array>) - : [] +/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */ +export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined { + const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] return subRooms.find((s) => s.SubRoomId === subRoomId) } @@ -276,8 +300,9 @@ export interface SaveSubRoomDataInput { /** * Persist a room-save against a specific subroom: point the subroom at its newly * uploaded data blob (what the loader later downloads) and record the room-level - * fields from the save. Returns the updated room, or null when the room or - * subroom doesn't exist. The whole room JSON is rewritten (subrooms live in it). + * fields from the save. Returns the updated (hydrated) room, or null when the room or + * subroom doesn't exist. The subroom row is updated in the `subroom` table; the + * room-level fields are written to the room blob. */ export async function saveSubRoomData( db: D1Database, @@ -288,7 +313,7 @@ export async function saveSubRoomData( ): Promise { const room = await getRoomById(db, roomId) if (!room) return null - const sub = findSubRoom(room, subRoomId) + const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return null // Populate the subroom's creator on first save — it starts null, and the @@ -300,17 +325,19 @@ export async function saveSubRoomData( if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion + await updateSubRoom(db, sub) // Room-level fields carried by the save. if (typeof input.description === 'string') room.Description = input.description if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage - await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(room)) + .bind(roomId, serializeRoom(room)) .run() - return room + + // Re-hydrate so the returned room reflects the just-saved subroom. + return hydrateRoom(db, room) } /** Fields from the client's subroom `modify` form (each applied only when supplied). */ @@ -322,9 +349,9 @@ export interface ModifySubRoomInput { /** * Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers - * (the fields the client's subroom `modify` form carries). Only the supplied - * fields are changed; the whole room JSON is rewritten (subrooms live in it). - * Returns the updated room, or null when the room or subroom doesn't exist. + * (the fields the client's subroom `modify` form carries). Only the supplied fields + * are changed; the subroom row is updated in the `subroom` table. Returns the updated + * (hydrated) room, or null when the room or subroom doesn't exist. */ export async function modifySubRoom( db: D1Database, @@ -332,61 +359,37 @@ export async function modifySubRoom( subRoomId: number, input: ModifySubRoomInput ): Promise { - const room = await getRoomById(db, roomId) - if (!room) return null - const sub = findSubRoom(room, subRoomId) + const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return null if (input.name !== undefined) sub.Name = input.name if (input.accessibility !== undefined) sub.Accessibility = input.accessibility if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers + await updateSubRoom(db, sub) - await db - .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(room)) - .run() - return room + return getRoomById(db, roomId) } /** * Clone an existing subroom into a new subroom of the same room, owned by * `accountId`. The copy keeps the source's scene/settings (and its saved data - * blobs, so it loads identical content) but gets a fresh SubRoomId — the next - * integer above the room's current subrooms. Returns the updated room and the new - * subroom, or null when the room or source subroom doesn't exist. + * blobs, so it loads identical content) but gets a fresh globally-unique SubRoomId + * minted from the `subroom` table's autoincrement sequence. Returns the updated + * (hydrated) room and the new subroom, or null when the room or source subroom + * doesn't exist. */ export async function cloneSubRoom( db: D1Database, roomId: number, subRoomId: number, accountId: number -): Promise<{ room: Room; subRoom: Record } | null> { - const room = await getRoomById(db, roomId) - if (!room) return null - const source = findSubRoom(room, subRoomId) +): Promise<{ room: Room; subRoom: SubRoom } | null> { + const source = await getSubRoom(db, roomId, subRoomId) if (!source) return null - const subRooms = Array.isArray(room.SubRooms) - ? (room.SubRooms as Array>) - : [] - const nextSubRoomId = - subRooms.reduce((max, s) => { - const id = typeof s.SubRoomId === 'number' ? s.SubRoomId : 0 - return id > max ? id : max - }, 0) + 1 - - const subRoom: Record = { - ...source, - SubRoomId: nextSubRoomId, - RoomId: room.RoomId, - CreatorAccountId: accountId, - } - - room.SubRooms = [...subRooms, subRoom] - await db - .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') - .bind(roomId, JSON.stringify(room)) - .run() + const subRoom = await insertSubRoom(db, roomId, { ...source, CreatorAccountId: accountId }) + const room = await getRoomById(db, roomId) + if (!room) return null return { room, subRoom } } @@ -397,10 +400,147 @@ interface RoomRow { const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) const parseAll = (rows: RoomRow[]): Room[] => rows.map((r) => JSON.parse(r.data) as Room) +// ---- Subrooms ------------------------------------------------------------- +// Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a +// room's `SubRooms` array is reconstructed on read and never stored in the room blob. + +/** A stored subroom — the parsed JSON blob (its client shape). */ +export type SubRoom = Record + +interface SubRoomRow { + sub_room_id: number + room_id: number + data: string +} + +/** Materialize a subroom row into its client shape, with the columns authoritative. */ +const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({ + ...(JSON.parse(row.data) as SubRoom), + SubRoomId: row.sub_room_id, + RoomId: row.room_id, +}) + +/** Serialize a subroom for storage — drop the id/room columns from the JSON blob. */ +const serializeSubRoom = (sub: SubRoom, roomId: number): string => { + const { SubRoomId: _id, RoomId: _room, ...rest } = sub + return JSON.stringify({ ...rest, RoomId: roomId }) +} + +/** + * Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never + * gets denormalized back into the room JSON (subrooms are the `subroom` table's job). + */ +const serializeRoom = (room: Room): string => { + const { SubRooms: _subRooms, ...rest } = room + return JSON.stringify(rest) +} + +/** Attach each room's `SubRooms` array from the subroom table (one batched query). */ +async function attachSubRooms(db: D1Database, rooms: Room[]): Promise { + const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n)) + if (ids.length === 0) { + for (const room of rooms) room.SubRooms = [] + return + } + const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') + const { results } = await db + .prepare( + `SELECT sub_room_id, room_id, data FROM subroom + WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` + ) + .bind(...ids) + .all() + const byRoom = new Map() + for (const r of results) { + const list = byRoom.get(r.room_id) ?? [] + list.push(parseSubRoomRow(r)) + byRoom.set(r.room_id, list) + } + for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? [] +} + +/** Hydrate a single room's `SubRooms` (no-op for null). */ +async function hydrateRoom(db: D1Database, room: Room | null): Promise { + if (room) await attachSubRooms(db, [room]) + return room +} + +/** Hydrate many rooms' `SubRooms` in one batched query. */ +async function hydrateRooms(db: D1Database, rooms: Room[]): Promise { + await attachSubRooms(db, rooms) + return rooms +} + +/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */ +export async function getSubRoom( + db: D1Database, + roomId: number, + subRoomId: number +): Promise { + const row = await db + .prepare('SELECT sub_room_id, room_id, data FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') + .bind(roomId, subRoomId) + .first() + return row ? parseSubRoomRow(row) : null +} + +/** All of a room's subrooms, ordered by SubRoomId. */ +export async function getSubRooms(db: D1Database, roomId: number): Promise { + const { results } = await db + .prepare('SELECT sub_room_id, room_id, data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id') + .bind(roomId) + .all() + return results.map(parseSubRoomRow) +} + +/** + * Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the + * table's autoincrement sequence. Returns the created subroom (with its new id). + */ +export async function insertSubRoom( + db: D1Database, + roomId: number, + sub: SubRoom +): Promise { + const row = await db + .prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id') + .bind(roomId, serializeSubRoom(sub, roomId)) + .first<{ sub_room_id: number }>() + return { ...sub, SubRoomId: row!.sub_room_id, RoomId: roomId } +} + +/** Overwrite a subroom's stored data blob in place. */ +async function updateSubRoom(db: D1Database, sub: SubRoom): Promise { + await db + .prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1') + .bind(sub.SubRoomId, serializeSubRoom(sub, Number(sub.RoomId))) + .run() +} + +/** + * Seed a room together with its subrooms — inserts the room (SubRooms stripped from the + * blob) and each embedded subroom into the `subroom` table, preserving explicit ids. + * Used by the migration's data model in tests (mirrors 0007_subrooms.sql's backfill). + */ +export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise { + const roomId = Number(room.RoomId) + const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] + await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() + for (const sub of subRooms) { + await db + .prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)') + .bind(Number(sub.SubRoomId), roomId, serializeSubRoom(sub, roomId)) + .run() + } +} + /** Look up a single room by its RoomId. */ export async function getRoomById(db: D1Database, roomId: number): Promise { - return parseOne( - await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first() + return hydrateRoom( + db, + parseOne( + await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first() + ) ) } @@ -420,11 +560,14 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise /** Look up a single room by name (case-insensitive exact match). */ export async function getRoomByName(db: D1Database, name: string): Promise { - return parseOne( - await db - .prepare('SELECT data FROM room WHERE name_lower = ?1') - .bind(name.toLowerCase()) - .first() + return hydrateRoom( + db, + parseOne( + await db + .prepare('SELECT data FROM room WHERE name_lower = ?1') + .bind(name.toLowerCase()) + .first() + ) ) } @@ -436,7 +579,7 @@ export async function getRoomsByIds(db: D1Database, ids: number[]): Promise() - return parseAll(results) + return hydrateRooms(db, parseAll(results)) } /** All rooms created by an account (e.g. their dorm). */ @@ -445,7 +588,7 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom .prepare('SELECT data FROM room WHERE creator_account_id = ?1') .bind(accountId) .all() - return parseAll(results) + return hydrateRooms(db, parseAll(results)) } /** @@ -497,7 +640,7 @@ export async function getFavoritedRooms( ) .bind(playerId) .all() - return parseAll(results).slice(skip, skip + take) + return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** @@ -522,7 +665,7 @@ export async function getVisitedRooms( ) .bind(playerId) .all() - return parseAll(results).slice(skip, skip + take) + return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** A player's interaction state with a room. */ @@ -686,7 +829,10 @@ export async function searchRooms( } } - return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } + return { + Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), + TotalResults: rooms.length, + } } /** Engagement score used to order the hot feed (cheers weigh most, then favorites). */ @@ -723,7 +869,10 @@ export async function getHotRooms( const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) - return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } + return { + Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), + TotalResults: rooms.length, + } } /** @@ -741,10 +890,13 @@ export async function getRecommendedRooms( ): Promise { const { results } = await db.prepare('SELECT data FROM room').all() const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) - return parseAll(results) - .filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true) - .sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) - .slice(skip, skip + take) + return hydrateRooms( + db, + parseAll(results) + .filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true) + .sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) + .slice(skip, skip + take) + ) } /** Compact room projection carried by a featured-room group. */ @@ -842,7 +994,10 @@ export async function getSimilarRooms( roomIdOf(a.room) - roomIdOf(b.room) ) const rooms = scored.map((x) => x.room) - return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } + return { + Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), + TotalResults: rooms.length, + } } /** @@ -856,10 +1011,13 @@ export async function getBaseRooms(db: D1Database, skip: number, take: number): const { results } = await db.prepare('SELECT data FROM room').all() const base = new Set(['base']) const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) - return parseAll(results) - .filter((r) => roomHasAnyTag(r, base)) - .sort((a, b) => roomIdOf(a) - roomIdOf(b)) - .slice(skip, skip + take) + return hydrateRooms( + db, + parseAll(results) + .filter((r) => roomHasAnyTag(r, base)) + .sort((a, b) => roomIdOf(a) - roomIdOf(b)) + .slice(skip, skip + take) + ) } /** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */ @@ -878,11 +1036,14 @@ export async function getUsername(db: D1Database, accountId: number): Promise { - return parseOne( - await db - .prepare('SELECT data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') - .bind(accountId) - .first() + return hydrateRoom( + db, + parseOne( + await db + .prepare('SELECT data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') + .bind(accountId) + .first() + ) ) } @@ -924,9 +1085,12 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr Roles: [ { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, ], - SubRooms: [{ ...templateSub, CreatorAccountId: accountId }], CreatedAt: new Date().toISOString(), } - await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(room)).run() + // serializeRoom drops any SubRooms carried over from the template; the dorm's own + // subroom is inserted into the subroom table below with a fresh globally-unique id. + await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() + const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId }) + room.SubRooms = [subRoom] return room }