From d5ccad51d375c3b0c2eb1e9854ae4d655dc001a9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 28 Jul 2026 20:29:20 -0400 Subject: [PATCH] fixed room and subroom saving, add room saves --- CLAUDE.md | 4 + apps/auth/src/auth.app.ts | 3 +- apps/match/src/match.app.ts | 9 +- apps/rooms/migrations/0008_subroom_saves.sql | 66 ++++ apps/rooms/src/openapi.ts | 45 ++- apps/rooms/src/rooms.app.ts | 51 ++- apps/rooms/src/test/integration/api.test.ts | 228 ++++++++++- packages/domain/src/rooms-db.ts | 380 +++++++++++++++++-- 8 files changed, 728 insertions(+), 58 deletions(-) create mode 100644 apps/rooms/migrations/0008_subroom_saves.sql diff --git a/CLAUDE.md b/CLAUDE.md index 67321bd..fb8d81f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,10 @@ inconsistency here without checking the client first. updated ROOM — the client re-renders the room's subroom list from `value`. Notably `value` is the room even for `clone`, whose product is a new SUBROOM; only the room-level `POST /rooms/:id/clone` returns the thing it created. +- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`), + NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads + nothing. The key must be present (null before the first save); read it via + `subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 42d54a3..d6e620f 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -18,6 +18,7 @@ import { setLoginContext, setPasswordHash, setPresence, + subRoomDataBlob, verifyPassword, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' @@ -119,7 +120,7 @@ async function placeNewPlayerInOrientation( subRoomId: num(sub?.SubRoomId, 1), roomInstanceType: RoomInstanceType.Public, location: str(sub?.UnitySceneId), - dataBlob: str(sub?.DataBlob), + dataBlob: subRoomDataBlob(sub), eventId: 0, clubId: 0, roomCode: '', diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 0070cb8..41b690b 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -27,6 +27,7 @@ import { RoomInstanceType, setPresence, setRoomInstanceInProgress, + subRoomDataBlob, } from '@repo/domain' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' @@ -34,7 +35,6 @@ import { validateAndGetAccountId } from '@repo/jwt' // Value import of the notify worker's NotificationType enum (its bundle has no runtime // deps), so /invite sends a typed MessageReceived frame instead of a magic number. import { NotificationType } from '../../notify/src/notification-types' - import { AUTHED, EMPTY_OK, @@ -363,7 +363,7 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) { roomId: num(room.RoomId, 1), subRoomId: num(sub?.SubRoomId, 1), location: str(sub?.UnitySceneId), - dataBlob: str(sub?.DataBlob), + dataBlob: subRoomDataBlob(sub), name, maxCapacity: num(sub?.MaxPlayers, 4), roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public, @@ -693,7 +693,10 @@ const app = new Hono() '`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared', 'by logout and otherwise expires on its TTL — but the hook is here for a future check.', ].join(' '), - requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'), + requestBody: form( + NotifyDisconnectRequest, + 'The disconnecting player and the instance they left' + ), responses: { 200: EMPTY_OK }, }), async (c) => { diff --git a/apps/rooms/migrations/0008_subroom_saves.sql b/apps/rooms/migrations/0008_subroom_saves.sql new file mode 100644 index 0000000..0ea8edd --- /dev/null +++ b/apps/rooms/migrations/0008_subroom_saves.sql @@ -0,0 +1,66 @@ +-- Room saves as first-class entities. A subroom POINTS at its saves by bare id — the +-- live/published one the loader downloads (`current_save_id`) and the creator's +-- unpublished one (`staged_save_id`, unused for now) — and `StagedSubRoomDataSaveId` +-- carries no subroom context, so a save id has to be globally unique to be resolvable. +-- Numbering saves per subroom (what the embedded `CurrentSave` did) makes every +-- subroom's first save id 1 and those pointers ambiguous. Same reasoning as 0007 for +-- SubRoomId. It also gives `GET …/subrooms/{id}/saves` real history to page over. +-- +-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS subroom_save ( + sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT, + sub_room_id INTEGER NOT NULL, + data TEXT NOT NULL + ); +CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id); + +ALTER TABLE subroom ADD COLUMN current_save_id INTEGER; +ALTER TABLE subroom ADD COLUMN staged_save_id INTEGER; + +-- Backfill 1: subrooms that already carry an embedded `CurrentSave` object. The two id +-- fields are dropped from the stored blob — the columns are authoritative and are +-- re-injected on read. +INSERT INTO subroom_save (sub_room_id, data) + SELECT + sub_room_id, + json_remove(json_extract(data, '$.CurrentSave'), '$.SubRoomDataSaveId', '$.SubRoomId') + FROM subroom + WHERE json_extract(data, '$.CurrentSave') IS NOT NULL; + +-- Backfill 2: subrooms saved before `CurrentSave` existed, whose blob key sits in the +-- flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. They hold real saved content +-- the client can't see (it reads only `CurrentSave`), so they become saves too rather +-- than reading as never-saved. Shape matches the reference's MapSave projection. +INSERT INTO subroom_save (sub_room_id, data) + SELECT + sub_room_id, + json_object( + 'UnitySubAssets', json('[]'), + 'ReferencedUnityAssets', json('[]'), + 'DataBlob', json_extract(data, '$.DataBlob'), + 'ReferencedUnityAssetIds', json('[]'), + 'PersistenceVersion', COALESCE(json_extract(data, '$.PersistenceVersion'), 0), + 'OMVersion', 0, + 'UgcSubVersion', 0, + 'SavedByAccountId', json_extract(data, '$.CreatorAccountId'), + 'SavedOnPlatform', 0, + 'SavedOnDeviceClass', 0, + 'Description', '', + 'Tags', json('[]'), + 'ModerationState', 0, + 'CreatedAt', COALESCE(json_extract(data, '$.DataSavedAt'), '1970-01-01T00:00:00.000Z') + ) + FROM subroom + WHERE json_extract(data, '$.CurrentSave') IS NULL + AND COALESCE(json_extract(data, '$.DataBlob'), '') <> ''; + +-- Point each subroom at the save just minted for it. At this moment a subroom has at +-- most one save row, so the correlated subquery is unambiguous. +UPDATE subroom SET current_save_id = ( + SELECT s.sub_room_data_save_id FROM subroom_save s WHERE s.sub_room_id = subroom.sub_room_id + ); + +-- Single source of truth: the save now lives in `subroom_save`, and +-- `StagedSubRoomDataSaveId` is served from the `staged_save_id` column. +UPDATE subroom SET data = json_remove(data, '$.CurrentSave', '$.StagedSubRoomDataSaveId'); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 1c3fbdc..0dcb0ba 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -138,14 +138,43 @@ export const LoadScreenDto = z.object({ Subtitle: z.string(), }) +/** + * A subroom's most recent room save — the `SubRoomDataSave` the client reads to find the + * scene-data blob to download. This is the ONLY place the loader looks for it, so a + * subroom whose `CurrentSave` is missing loads no saved content at all. + * + * The array fields are always empty here: we neither resolve nor record referenced Unity + * assets. They are still emitted because the client's parser expects them present. + */ +export const SubRoomDataSaveDto = z.object({ + UnitySubAssets: z.array(z.unknown()).describe('Always empty'), + ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'), + SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'), + SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'), + DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'), + ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'), + PersistenceVersion: z.int(), + OMVersion: z.int(), + UgcSubVersion: z.int(), + SavedByAccountId: z.int().nullable(), + SavedOnPlatform: z.int().describe('0 — the save request carries no platform'), + SavedOnDeviceClass: z.int().describe('0 — the save request carries no device class'), + Description: z.string().describe('The save comment; empty string when none'), + Tags: z.array(z.unknown()).describe('Always empty'), + ModerationState: z.int(), + CreatedAt: z.string(), + UnityAssetId: z.string().optional().describe('Emitted only when the save carried one'), +}) + /** * A subroom — a room's individual scene. Subrooms are their own table with a globally * unique, autoincrementing `SubRoomId` (the original game mints them from a single * sequence, not per-room); a room's `SubRooms` array is reconstructed on read. * * `CreatorAccountId` starts null on the seeded rooms and is filled in on the first save — - * the client NREs on a null one. The `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields only - * appear once the subroom has been saved at least once. + * the client NREs on a null one. `CurrentSave` is null until the first save; the flat + * `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields are legacy and are NOT what the client + * loads from. */ export const SubRoomDto = z.object({ SubRoomId: z.int(), @@ -161,7 +190,10 @@ export const SubRoomDto = z.object({ .describe('0 Private, 1 Public, 2 Unlisted, 3 Dev_only, 4 Dev_Unlisted — set independently'), ShouldAutoStageSaves: z.boolean(), StagedSubRoomDataSaveId: z.int().nullable(), - DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'), + CurrentSave: SubRoomDataSaveDto.nullable().describe( + 'The latest room save — where the client finds the scene blob. Null until first save' + ), + DataBlob: z.string().optional().describe('Legacy flat key; the client reads `CurrentSave`'), RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'), DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'), PersistenceVersion: z.int().optional(), @@ -440,7 +472,7 @@ export const SaveSubRoomDataRequest = z.object({ SubRoomData: z .object({ Filename: z.string() }) .optional() - .describe('The uploaded scene-data blob — becomes the subroom’s `DataBlob`'), + .describe('The uploaded scene-data blob — becomes the subroom’s `CurrentSave.DataBlob`'), RoomData: z .object({ Filename: z.string() }) .optional() @@ -455,8 +487,11 @@ export const SaveSubRoomDataRequest = z.object({ * save history (a save overwrites the subroom's blob inline), so it's always empty. */ export const SubRoomSavesPage = z.object({ - Results: z.array(z.unknown()).describe('Always empty — no save history is kept'), + Results: z + .array(SubRoomDataSaveDto) + .describe('At most one — the current save; we keep no history'), TotalResults: z.int(), + TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'), }) // ---- Session --------------------------------------------------------------- diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 8cee976..32ac9e4 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -25,6 +25,7 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + getSubRoomSaves, getVisitedRooms, modifySubRoom, removeCheer, @@ -1448,34 +1449,50 @@ const app = new Hono() } ) - // A subroom's saved-data versions — the room-history / "restore a save" list, paged as - // PagedResultsDTO (`{ Results, TotalResults }`). We don't keep a save - // history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so - // there are no distinct versions to list — this returns an empty page. The - // unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored. + // A subroom's saved-data versions — the room-history / "restore a save" list. Every + // save is its own `subroom_save` row (nothing is overwritten), so this is real + // history, newest first, paged by skip/take. .get( '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', describeRoute({ tags: ['Subrooms'], summary: 'A subroom’s saved-data versions', description: [ - 'The room-history / “restore a save” list, paged as', - '`PagedResultsDTO`. We keep no save history — a save (`POST', - '…/data`) overwrites the subroom’s current blob inline, so there are no distinct', - 'versions to list — and this is always an empty page. The', - '`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params are accepted and ignored.', + 'The room-history / “restore a save” list, newest first. Every room save appends a', + 'row rather than overwriting, so this is the subroom’s full history; it is empty', + 'only when the subroom has never been saved.', + '`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.', + '', + '`TotalResults` and `TotalCount` carry the same number: the client’s paged DTO and', + 'the reference disagree on the name, so both are emitted.', ].join(' '), parameters: [ roomIdParam, subRoomIdParam, stringQuery('unityAssetTarget', 'Accepted and ignored'), stringQuery('unityAssetVersion', 'Accepted and ignored'), - stringQuery('skip', 'Accepted and ignored — the page is always empty'), - stringQuery('take', 'Accepted and ignored — the page is always empty'), + stringQuery('skip', 'How many saves to skip (default 0)'), + stringQuery('take', 'How many saves to return (default all)'), ], - responses: { 200: json(SubRoomSavesPage, 'Always an empty page') }, + responses: { 200: json(SubRoomSavesPage, 'The subroom’s saves, newest first') }, }), - (c) => c.json({ Results: [], TotalResults: 0 }) + async (c) => { + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + // Scoped through the room so a subroom id from another room can't read its saves. + const room = await getRoomById(c.env.DB, roomId) + if (!room || !findSubRoom(room, subRoomId)) { + return c.json({ Results: [], TotalResults: 0, TotalCount: 0 }) + } + const saves = await getSubRoomSaves(c.env.DB, subRoomId) + + const skip = Number.parseInt(c.req.query('skip') ?? '', 10) + const take = Number.parseInt(c.req.query('take') ?? '', 10) + const from = Number.isNaN(skip) || skip < 0 ? 0 : skip + const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take) + + return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length }) + } ) // Save a subroom's data (room save). Auth-gated (401 with empty body). Editable @@ -1531,9 +1548,14 @@ const app = new Hono() // already returned 401 for a missing/invalid token). if (!canManageRoom(room, accountId)) return c.body(null, 403) + // The client uploads BOTH blobs to `storage` first and sends their keys here: + // `SubRoomData` is the scene blob (what the loader downloads), `RoomData` the + // metadata blob. `UnityAssetId`/`AutoPublish`/`OwnershipProof` are accepted + // and ignored. const body = (await c.req.json().catch(() => ({}))) as { RoomData?: { Filename?: string } SubRoomData?: { Filename?: string } + UnityAssetId?: string | null Description?: string PersistenceVersion?: number InventionUsage?: string @@ -1542,6 +1564,7 @@ const app = new Hono() const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, { subRoomDataFilename: body.SubRoomData?.Filename, roomDataFilename: body.RoomData?.Filename, + unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined, description: typeof body.Description === 'string' ? body.Description : undefined, persistenceVersion: typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 25c3918..728f4f1 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -988,23 +988,34 @@ describe('rooms endpoints', () => { expect(ok.status).toBe(200) expect(await bodyOf(ok)).toMatchObject({ SubRoomId: 2, - DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', RoomDataBlob: '5c618c920f6247efb8327e327d0b4417', CreatorAccountId: 1, PersistenceVersion: 41, + // The blob the client actually loads from lives on CurrentSave. + CurrentSave: { + SubRoomId: 2, + DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', + SavedByAccountId: 1, + PersistenceVersion: 41, + UnitySubAssets: [], + ReferencedUnityAssets: [], + ReferencedUnityAssetIds: [], + Tags: [], + }, }) - // It also persists — the GET returns the subroom with the new blob + creator. + // It also persists — the GET returns the subroom with the new save + creator. const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as { SubRoomId: number - DataBlob: string CreatorAccountId: number + CurrentSave: { DataBlob: string; SubRoomDataSaveId: number } } expect(sub).toMatchObject({ SubRoomId: 2, - DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', CreatorAccountId: 1, + CurrentSave: { DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f' }, }) + expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0) const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string PersistenceVersion: number @@ -1019,6 +1030,180 @@ describe('rooms endpoints', () => { expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 }) }) + it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => { + // The client loads a subroom's scene data from CurrentSave and nothing else, so + // the key must be PRESENT — the seeded rooms predate it and have no such field in + // their stored blob. `in` rather than a value check: absent and null differ here. + // Room 3 is seeded and never saved by another test (room 2 is the save fixture). + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/3`)).json()) as { + SubRooms: Array> + } + expect(room.SubRooms.length).toBeGreaterThan(0) + for (const sub of room.SubRooms) { + expect('CurrentSave' in sub).toBe(true) + expect(sub.CurrentSave).toBeNull() + } + }) + + it('a real client room-save body populates CurrentSave and comes back on GET /rooms/:id', async () => { + // The exact body the live client posts after uploading both blobs to `storage`: + // SubRoomData is the scene blob, RoomData the metadata blob. + const res = await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify({ + UnityAssetId: null, + RoomData: { Filename: '2026-07-28/b266ccd5-metadata', Hash: null, OwnershipProof: null }, + SubRoomData: { Filename: '2026-07-28/f176fc3b-scene', Hash: null, OwnershipProof: null }, + InventionUsage: 'CAE=', + PersistenceVersion: 51, + Description: 'TEST', + AutoPublish: false, + }), + }) + expect(res.status).toBe(200) + + // It must be visible on the room read — that's what the loader fetches. + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as { + SubRooms: Array<{ SubRoomId: number; CurrentSave: Record | null }> + } + const sub = room.SubRooms.find((s) => s.SubRoomId === 5)! + expect(sub.CurrentSave).toMatchObject({ + SubRoomId: 5, + DataBlob: '2026-07-28/f176fc3b-scene', + PersistenceVersion: 51, + SavedByAccountId: 1, + Description: 'TEST', + OMVersion: 0, + UgcSubVersion: 0, + ModerationState: 0, + }) + // No DataBlobHash — it is commented out of the reference DTO — and no UnityAssetId + // key at all, since the client sent null. + expect('DataBlobHash' in sub.CurrentSave!).toBe(false) + expect('UnityAssetId' in sub.CurrentSave!).toBe(false) + + // The save list serves that save rather than an empty page. + const firstId = sub.CurrentSave!.SubRoomDataSaveId as number + expect(firstId).toBeGreaterThan(0) + const saves = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`)).json()) as { + Results: Array<{ DataBlob: string }> + TotalResults: number + } + expect(saves.TotalResults).toBe(1) + expect(saves.Results[0]!.DataBlob).toBe('2026-07-28/f176fc3b-scene') + + // A second save appends rather than overwriting, and takes a fresh higher id. + await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify({ SubRoomData: { Filename: 'second.room' } }), + }) + const after = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`)).json()) as { + CurrentSave: { SubRoomDataSaveId: number; DataBlob: string; Description: string } + } + expect(after.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(firstId) + expect(after.CurrentSave.DataBlob).toBe('second.room') + + // Both saves are in the history, newest first — the first one is not lost. + const history = (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`)).json()) as { + Results: Array<{ DataBlob: string }> + TotalResults: number + } + expect(history.TotalResults).toBe(2) + expect(history.Results.map((s) => s.DataBlob)).toEqual([ + 'second.room', + '2026-07-28/f176fc3b-scene', + ]) + // A save with no Description records an empty string, not null. + expect(after.CurrentSave.Description).toBe('') + }) + + it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => { + // A subroom saved by the older code has its blob in the flat DataBlob field and no + // CurrentSave at all. seedRoomWithSubRooms mirrors the migration, so this covers + // the backfill: the flat fields become a save row the subroom points at, rather + // than reading as never-saved and hiding real content from the loader. + await seedRoomWithSubRooms(env.DB, { + RoomId: 820, + Name: 'LegacyShaped', + CreatorAccountId: 1, + SubRooms: [ + { + SubRoomId: 830, + Name: 'Legacy', + CreatorAccountId: 7, + UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', + MaxPlayers: 4, + Accessibility: 2, + DataBlob: 'legacy-blob.room', + DataSavedAt: '2024-03-04T05:06:07.000Z', + PersistenceVersion: 12, + }, + ], + }) + + const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as { + CurrentSave: Record + } + expect(sub.CurrentSave).toMatchObject({ + SubRoomId: 830, + DataBlob: 'legacy-blob.room', + PersistenceVersion: 12, + SavedByAccountId: 7, + CreatedAt: '2024-03-04T05:06:07.000Z', + UnitySubAssets: [], + Tags: [], + }) + // Stable across reads — it's a stored row now, not something rebuilt per request. + const again = (await (await SELF.fetch(`${ORIGIN}/rooms/820/subrooms/830/data`)).json()) as { + CurrentSave: { SubRoomDataSaveId: number } + } + expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId) + }) + + it('save ids are globally unique across subrooms, so a bare id resolves', async () => { + // StagedSubRoomDataSaveId points at a save by bare id with no subroom context, so + // per-subroom numbering (every subroom's first save being 1) would be ambiguous. + const save = async (roomId: number, subRoomId: number) => + SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }), + }) + const idOf = async (roomId: number, subRoomId: number) => { + const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`) + return ((await res.json()) as { CurrentSave: { SubRoomDataSaveId: number } }).CurrentSave + .SubRoomDataSaveId + } + + // Two different subrooms, each getting their FIRST save. + await save(6, 6) + await save(7, 7) + expect(await idOf(6, 6)).not.toBe(await idOf(7, 7)) + }) + + it('a cloned subroom re-points CurrentSave at the copy, not the source', async () => { + // Save room 2's subroom so there is a CurrentSave to copy. + await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, { + method: 'POST', + headers: { ...(await bearer('1')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ SubRoomData: { Filename: 'cloned-source.room' } }), + }) + + const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, { + method: 'POST', + headers: await bearer('1'), + }) + const body = (await res.json()) as { + value: { SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomId: number } | null }> } + } + const clone = body.value.SubRooms.find((s) => s.SubRoomId !== 2 && s.CurrentSave !== null)! + expect(clone).toBeDefined() + // The copy's save must claim the COPY, or the client resolves it against the source. + expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId) + }) + it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => { // The lowercase `{ success, error, value }` envelope this endpoint returns. type TagResult = { @@ -1558,12 +1743,35 @@ describe('rooms endpoints', () => { expect((await SELF.fetch(`${ORIGIN}/rooms/700/subrooms/900/data`)).status).toBe(200) }) - it('GET /rooms/:id/subrooms/:sid/saves returns an empty paged result', async () => { - const res = await SELF.fetch( - `${ORIGIN}/rooms/2/subrooms/2/saves?unityAssetTarget=0&unityAssetVersion=1&skip=0&take=20` - ) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) + it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => { + type Page = { + Results: Array<{ SubRoomId: number; SubRoomDataSaveId: number }> + TotalResults: number + TotalCount: number + } + const page = async (query: string) => + (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`)).json()) as Page + + // Room 2's subroom is saved several times by the tests above — each save appended. + const all = await page('?unityAssetTarget=0&unityAssetVersion=1') + expect(all.Results.length).toBeGreaterThan(1) + expect(all.Results.every((s) => s.SubRoomId === 2)).toBe(true) + // Newest first: ids descend. + const ids = all.Results.map((s) => s.SubRoomDataSaveId) + expect([...ids].sort((a, b) => b - a)).toEqual(ids) + // Both spellings of the count, and they agree with the list. + expect(all.TotalResults).toBe(all.Results.length) + expect(all.TotalCount).toBe(all.TotalResults) + + // skip/take actually page rather than being ignored. + const paged = await page('?skip=1&take=1') + expect(paged.Results).toHaveLength(1) + expect(paged.Results[0]!.SubRoomDataSaveId).toBe(ids[1]) + expect(paged.TotalResults).toBe(all.TotalResults) + + // A never-saved subroom pages empty rather than 404ing. + const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`) + expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 }) }) it('GET /openapi.json documents every route', async () => { diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 7e536e2..b33a41e 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -57,9 +57,31 @@ 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 + data TEXT NOT NULL, + current_save_id INTEGER, + staged_save_id INTEGER )`, `CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`, + // Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a + // globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got + // their own table in 0007. It HAS to be global because a subroom points at saves by + // bare id: `current_save_id` is the live/published save the loader downloads, + // `staged_save_id` the creator's unpublished one. Per-subroom numbering would make + // every subroom's first save id 1 and those pointers ambiguous. + // + // `data` holds the save's client shape minus its two id fields; the columns are + // authoritative and are re-injected on read, exactly how `subroom` treats its own ids. + // A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is + // never stored in the subroom blob. + // + // Part of this DDL rather than its own export: reading a subroom joins this table, so + // applying one without the other yields a schema that can't serve a room. + `CREATE TABLE IF NOT EXISTS subroom_save ( + sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT, + sub_room_id INTEGER NOT NULL, + data TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ @@ -288,13 +310,109 @@ export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined /** Fields from the client's room-save POST body. */ export interface SaveSubRoomDataInput { - /** Uploaded blob key for this subroom's scene data (becomes the subroom's DataBlob). */ + /** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */ subRoomDataFilename?: string - /** Uploaded blob key for the room-level data. */ + /** Uploaded blob key for the room-level METADATA blob (a separate upload). */ roomDataFilename?: string description?: string persistenceVersion?: number inventionUsage?: string + /** Optional baked-asset id; emitted on the save only when present. */ + unityAssetId?: string +} + +/** + * A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene + * data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave` + * loads nothing, no matter what the (legacy, flat) `DataBlob` field says. + */ +export type SubRoomDataSave = Record + +/** + * The scene-data blob key the client should download for a subroom. Prefers the + * authoritative `CurrentSave.DataBlob` and falls back to the flat `DataBlob` that + * subrooms written before `CurrentSave` existed (and the `0001_init.sql` dorm seed) + * still carry. Shared so the `match` and `auth` room-instance payloads resolve the + * blob the same way the client's own loader does. + */ +export function subRoomDataBlob(sub: SubRoom | undefined | null): string { + const save = sub?.CurrentSave + if (save && typeof save === 'object') { + const blob = (save as SubRoomDataSave).DataBlob + if (typeof blob === 'string' && blob !== '') return blob + } + return typeof sub?.DataBlob === 'string' ? sub.DataBlob : '' +} + +/** Fields that vary between a real save and one reconstructed from the legacy shape. */ +interface BuildSaveInput { + subRoomId: unknown + dataBlob: string + persistenceVersion: number + savedByAccountId: unknown + description: string + createdAt: string + unityAssetId?: string +} + +/** + * Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave` + * projection. The four array fields are always empty (we neither resolve nor record + * referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when + * the save actually carried one, exactly as the reference does. There is deliberately no + * `DataBlobHash`: it is commented out of the reference DTO and absent from its output. + * + * `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving + * player's live platform/device, which the save request doesn't carry and we don't track. + * + * Shared by the save path and the legacy-shape reconstruction so the two can't drift. + */ +function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave { + const save: SubRoomDataSave = { + UnitySubAssets: [], + ReferencedUnityAssets: [], + SubRoomId: input.subRoomId, + DataBlob: input.dataBlob, + ReferencedUnityAssetIds: [], + PersistenceVersion: input.persistenceVersion, + OMVersion: 0, + UgcSubVersion: 0, + SavedByAccountId: input.savedByAccountId, + SavedOnPlatform: 0, + SavedOnDeviceClass: 0, + Description: input.description, + Tags: [], + ModerationState: 0, + CreatedAt: input.createdAt, + } + if (input.unityAssetId) save.UnityAssetId = input.unityAssetId + return save +} + +/** + * Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob + * key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those + * subrooms hold real saved content the client cannot see (it reads `CurrentSave` only), + * so they get a save of their own rather than reading as never-saved. Mirrors backfill 2 + * of migration 0008 — keep the two in sync. + * + * Returns null when there is genuinely nothing saved, the honest answer for a fresh + * subroom. + */ +function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null { + const blob = sub.DataBlob + if (typeof blob !== 'string' || blob === '') return null + const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString() + return buildSubRoomSave({ + subRoomId: sub.SubRoomId, + dataBlob: blob, + persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0, + // The legacy shape never recorded who saved; the subroom's creator is the best + // available answer (the save path is owner/co-owner gated). + savedByAccountId: sub.CreatorAccountId ?? null, + description: '', + createdAt: savedAt, + }) } /** @@ -320,8 +438,37 @@ export async function saveSubRoomData( // client NREs on a null CreatorAccountId. Only the owner reaches this path. if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId - // Point the subroom at the newly-uploaded data blobs and stamp the save. - if (input.subRoomDataFilename) sub.DataBlob = input.subRoomDataFilename + // Append a new save row and publish it. The blob the loader downloads lives on the + // save — a subroom whose current_save_id resolves to nothing loads nothing — so this + // never touches the flat DataBlob field. Previous saves stay in the table as history. + const previous = + sub.CurrentSave && typeof sub.CurrentSave === 'object' + ? (sub.CurrentSave as SubRoomDataSave) + : undefined + const priorVersion = previous?.PersistenceVersion + const priorBlob = previous?.DataBlob + const save = await insertSubRoomSave( + db, + subRoomId, + buildSubRoomSave({ + subRoomId, + // A save that carries no new blob (e.g. a description-only save) keeps the one + // the subroom already loads from. + dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''), + persistenceVersion: + input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0), + savedByAccountId: accountId, + // The save comment — empty string, not null, when the save carries none (the + // reference's `roomDesc ?? ""`). Also written to the room below. + description: input.description ?? '', + createdAt: new Date().toISOString(), + unityAssetId: input.unityAssetId, + }) + ) + // Publish it. Staging (AutoPublish:false → staged_save_id) is deliberately not wired + // up yet: always publishing is what makes a save actually load. + await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId)) + if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion @@ -434,7 +581,8 @@ export async function createSubRoom( IsSandbox: true, LastModeratedSaveModerationState: 0, ShouldAutoStageSaves: true, - StagedSubRoomDataSaveId: null, + // Nothing saved yet — the first room save mints one and points current_save_id + // at it. Until then the subroom reads with `CurrentSave: null`. }) // Refresh the hydrated SubRooms so the returned room includes the one just inserted. await attachSubRooms(db, [room]) @@ -456,10 +604,14 @@ export async function deleteSubRoom( if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' } if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' } - await db - .prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') - .bind(roomId, subRoomId) - .run() + await db.batch([ + db + .prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') + .bind(roomId, subRoomId), + // The saves go with it — nothing can reference them once the subroom is gone. + // The blobs they point at are left in R2, like a deleted room's images. + db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId), + ]) const room = await getRoomById(db, roomId) if (!room) return { ok: false, reason: 'not_found' } @@ -484,18 +636,41 @@ interface SubRoomRow { sub_room_id: number room_id: number data: string + current_save_id: number | null + staged_save_id: number | null } -/** Materialize a subroom row into its client shape, with the columns authoritative. */ +/** The columns every subroom read needs — the blob plus its two save pointers. */ +const SUBROOM_COLUMNS = 'sub_room_id, room_id, data, current_save_id, staged_save_id' + +/** + * Materialize a subroom row into its client shape, with the columns authoritative. + * `CurrentSave` is left undefined here and filled in by {@link attachCurrentSaves} — it + * lives in `subroom_save`, and resolving it per row would be a query each. Callers must + * go through the helpers below so the key is never missing: the client reads the scene + * blob from `CurrentSave` and nowhere else, so a subroom without one loads nothing. + */ const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({ ...(JSON.parse(row.data) as SubRoom), SubRoomId: row.sub_room_id, RoomId: row.room_id, + // Served from the column, not the blob — the creator's unpublished save (unused for + // now, but the client expects the key present). + StagedSubRoomDataSaveId: row.staged_save_id, }) -/** Serialize a subroom for storage — drop the id/room columns from the JSON blob. */ +/** + * Serialize a subroom for storage — drop the id/room columns and the save fields that + * are columns or their own table, so the blob never holds a stale copy of either. + */ const serializeSubRoom = (sub: SubRoom, roomId: number): string => { - const { SubRoomId: _id, RoomId: _room, ...rest } = sub + const { + SubRoomId: _id, + RoomId: _room, + CurrentSave: _save, + StagedSubRoomDataSaveId: _staged, + ...rest + } = sub return JSON.stringify({ ...rest, RoomId: roomId }) } @@ -508,6 +683,45 @@ const serializeRoom = (room: Room): string => { return JSON.stringify(rest) } +/** + * Fill in each subroom's `CurrentSave` from `subroom_save`, in ONE query for the whole + * batch. Every subroom ends up with the key present — null when it points at no save + * (never saved) or the pointer dangles — because the client's loader reads it directly. + * + * `rows` must line up with `subs` positionally; the pointer lives on the row, not the + * parsed blob. + */ +async function attachCurrentSaves( + db: D1Database, + subs: SubRoom[], + rows: SubRoomRow[] +): Promise { + const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))] + const byId = new Map() + if (saveIds.length > 0) { + const placeholders = saveIds.map((_, i) => `?${i + 1}`).join(',') + const { results } = await db + .prepare( + `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save + WHERE sub_room_data_save_id IN (${placeholders})` + ) + .bind(...saveIds) + .all() + for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r)) + } + subs.forEach((sub, i) => { + const id = rows[i]!.current_save_id + sub.CurrentSave = id == null ? null : (byId.get(id) ?? null) + }) +} + +/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */ +async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise { + const subs = rows.map(parseSubRoomRow) + await attachCurrentSaves(db, subs, rows) + return subs +} + /** 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)) @@ -518,17 +732,18 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise { const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare( - `SELECT sub_room_id, room_id, data FROM subroom + `SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` ) .bind(...ids) .all() + const subs = await parseSubRoomRows(db, results) const byRoom = new Map() - for (const r of results) { + results.forEach((r, i) => { const list = byRoom.get(r.room_id) ?? [] - list.push(parseSubRoomRow(r)) + list.push(subs[i]!) byRoom.set(r.room_id, list) - } + }) for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? [] } @@ -551,19 +766,96 @@ export async function getSubRoom( 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') + .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`) .bind(roomId, subRoomId) .first() - return row ? parseSubRoomRow(row) : null + if (!row) return null + return (await parseSubRoomRows(db, [row]))[0]! } /** 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') + .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`) .bind(roomId) .all() - return results.map(parseSubRoomRow) + return parseSubRoomRows(db, results) +} + +// ---- Subroom saves -------------------------------------------------------- + +interface SubRoomSaveRow { + sub_room_data_save_id: number + sub_room_id: number + data: string +} + +/** Materialize a save row, with its two id columns authoritative over the blob. */ +const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({ + ...(JSON.parse(row.data) as SubRoomDataSave), + SubRoomDataSaveId: row.sub_room_data_save_id, + SubRoomId: row.sub_room_id, +}) + +/** Serialize a save for storage — the id columns own those two fields, not the blob. */ +const serializeSubRoomSave = (save: SubRoomDataSave): string => { + const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save + return JSON.stringify(rest) +} + +/** + * Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from + * the table's autoincrement sequence. Returns the stored save with its new id. + */ +async function insertSubRoomSave( + db: D1Database, + subRoomId: number, + save: SubRoomDataSave +): Promise { + const row = await db + .prepare( + 'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id' + ) + .bind(subRoomId, serializeSubRoomSave(save)) + .first<{ sub_room_data_save_id: number }>() + return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId } +} + +/** + * A subroom's save history, newest first. Unlike the old inline model this is real + * history: every save is its own row and none are overwritten. + */ +export async function getSubRoomSaves( + db: D1Database, + subRoomId: number +): Promise { + const { results } = await db + .prepare( + `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save + WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC` + ) + .bind(subRoomId) + .all() + return results.map(parseSubRoomSaveRow) +} + +/** + * A single save by its globally-unique id, scoped to the subroom that owns it (the + * restore-a-save lookup). Null when the id is unknown or belongs to another subroom. + */ +export async function getSubRoomSaveById( + db: D1Database, + subRoomId: number, + saveId: number +): Promise { + const row = await db + .prepare( + `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save + WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2` + ) + .bind(saveId, subRoomId) + .first() + return row ? parseSubRoomSaveRow(row) : null } /** @@ -579,7 +871,23 @@ export async function insertSubRoom( .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 } + const subRoomId = row!.sub_room_id + const created: SubRoom = { + ...sub, + SubRoomId: subRoomId, + RoomId: roomId, + CurrentSave: null, + StagedSubRoomDataSaveId: null, + } + // A copied subroom (room clone, subroom clone) carries the source's save. It gets its + // OWN row — a save belongs to exactly one subroom, so sharing the source's id would + // make the copy's content follow the source's future saves. + if (sub.CurrentSave && typeof sub.CurrentSave === 'object') { + const copy = await insertSubRoomSave(db, subRoomId, sub.CurrentSave as SubRoomDataSave) + await setCurrentSave(db, subRoomId, Number(copy.SubRoomDataSaveId)) + created.CurrentSave = copy + } + return created } /** Overwrite a subroom's stored data blob in place. */ @@ -590,20 +898,35 @@ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise { .run() } +/** Point a subroom at its live/published save. */ +async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise { + await db + .prepare('UPDATE subroom SET current_save_id = ?2 WHERE sub_room_id = ?1') + .bind(subRoomId, saveId) + .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). + * blob) and each embedded subroom into the `subroom` table, preserving explicit ids. Any + * subroom carrying a `CurrentSave` gets it inserted into `subroom_save` and pointed at, + * mirroring 0008's backfill the way this mirrors 0007's. */ 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) { + const subRoomId = Number(sub.SubRoomId) await db .prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)') - .bind(Number(sub.SubRoomId), roomId, serializeSubRoom(sub, roomId)) + .bind(subRoomId, roomId, serializeSubRoom(sub, roomId)) .run() + const seeded = sub.CurrentSave ?? legacySubRoomSave(sub) + if (seeded && typeof seeded === 'object') { + const save = await insertSubRoomSave(db, subRoomId, seeded as SubRoomDataSave) + await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId)) + } } } @@ -628,6 +951,13 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise await db.batch([ db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), + // Saves first — they're keyed by subroom, so they'd be unreachable afterwards. + db + .prepare( + 'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)' + ) + .bind(roomId), + db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId), ]) }