diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 4290f93..08b4311 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -255,7 +255,10 @@ export const SubRoomDto = z.object({ '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'), + RoomDataBlob: z + .string() + .optional() + .describe('Legacy; the room-level blob is the ROOM’s `DataBlob`. Not a key the client knows'), DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'), PersistenceVersion: z.int().optional(), InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'), @@ -304,6 +307,11 @@ export const RoomDto = z.object({ PublishedAt: z.string(), BecameRRStudioRoomAt: z.string().nullable(), Stats: RoomStatsDto, + DataBlob: z + .string() + .optional() + .describe('The room-level metadata blob a room save uploads as `RoomData`; absent until one'), + DataBlobHash: z.string().nullable().optional().describe('`RoomData.Hash` from that save'), BoostCount: z .int() .describe('Boosts on the room. Nothing grants boosts here, so always 0 — but present'), @@ -877,9 +885,11 @@ export const SaveSubRoomDataRequest = z.object({ .optional() .describe('The uploaded scene-data blob — becomes the subroom’s `CurrentSave.DataBlob`'), RoomData: z - .object({ Filename: z.string() }) + .object({ Filename: z.string(), Hash: z.string().nullable().optional() }) .optional() - .describe('The uploaded room-level data blob — becomes `RoomDataBlob`'), + .describe( + 'The uploaded room-level metadata blob — becomes the ROOM’s `DataBlob`/`DataBlobHash`' + ), Description: z .string() .optional() diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 2beed05..b7228c7 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -3606,10 +3606,11 @@ const app = new Hono() 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. `OwnershipProof` is accepted and ignored. + // `SubRoomData` is the scene blob (what the loader downloads, onto the subroom's + // save), `RoomData` the room-level metadata blob (onto the ROOM, as `DataBlob`). + // `OwnershipProof` is accepted and ignored. const body = (await c.req.json().catch(() => ({}))) as { - RoomData?: { Filename?: string } + RoomData?: { Filename?: string; Hash?: string | null } SubRoomData?: { Filename?: string; Hash?: string | null } UnityAssetId?: string | null Description?: string @@ -3623,6 +3624,7 @@ const app = new Hono() subRoomDataHash: typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined, roomDataFilename: body.RoomData?.Filename, + roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined, unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined, autoPublish: body.AutoPublish === true, description: typeof body.Description === 'string' ? body.Description : undefined, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 3249dcd..d7dd821 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -3552,10 +3552,20 @@ describe('rooms endpoints', () => { (s) => s.SubRoomId === 2 )! expect(savedSub).toMatchObject({ - RoomDataBlob: '5c618c920f6247efb8327e327d0b4417', CreatorAccountId: 1, PersistenceVersion: 41, }) + // `RoomData` is the ROOM-level metadata blob: it lands on the room as `DataBlob`, which + // is where the client reads it — not on the subroom, which has no field for it. + expect(savedSub).not.toHaveProperty('RoomDataBlob') + expect(saved.value.room).toMatchObject({ + DataBlob: '5c618c920f6247efb8327e327d0b4417', + DataBlobHash: null, + }) + expect(await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()).toMatchObject({ + DataBlob: '5c618c920f6247efb8327e327d0b4417', + DataBlobHash: null, + }) expect(savedSub.CurrentSave).toMatchObject({ DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', }) diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 31d6b41..883c661 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -37,7 +37,12 @@ import type { App } from './context' * * RoomSave (1) lands under `room/` (not `roomsave/`) so the `cdn` worker's * `GET /room/:dataBlob` route serves the blob back — both bind the same - * `recflare-cdn` bucket, so the key prefixes must match. + * `recflare-cdn` bucket, so the key prefixes must match. RoomMetadata (6) — the + * room-level blob a room save posts as `RoomData`, which the room then names as its + * `DataBlob` — lands under `room/` too: the client fetches it from the same + * `/room/` path it fetches the scene from. It once went under `roommetadata/`, + * where nothing ever asked for it. The two never collide — every upload is named by + * a fresh GUID under the day's folder. */ const UPLOAD_SUBFOLDER: Record = { 1: 'room', @@ -45,7 +50,7 @@ const UPLOAD_SUBFOLDER: Record = { 3: 'image', 4: 'video', 5: 'invention', - 6: 'roommetadata', + 6: 'room', } /** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */ diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index c7e5331..cf3fe66 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -63,7 +63,7 @@ it('POST /upload 401s without a token', async () => { expect(res.status).toBe(401) }) -it('POST /upload stores a RoomMetadata (FileType 6) file under roommetadata/ and returns its name', async () => { +it('POST /upload stores a RoomMetadata (FileType 6) file under room/ and returns its name', async () => { // Mirrors the client's multipart upload: FileType=6, File=. const bytes = new Uint8Array([0x10, 0x02, 0x1a, 0x00]) const res = await SELF.fetch(`${ORIGIN}/upload`, { @@ -77,8 +77,9 @@ it('POST /upload stores a RoomMetadata (FileType 6) file under roommetadata/ and /^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ) - // The bytes are persisted in the shared CDN bucket under the type subfolder. - const stored = await env.CDN_ASSETS.get(`roommetadata/${filename}`) + // The bytes are persisted in the shared CDN bucket under `room/`, the same folder as a + // scene blob: the client downloads the room's `DataBlob` from the cdn's `/room/`. + const stored = await env.CDN_ASSETS.get(`room/${filename}`) expect(stored).not.toBeNull() expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes) }) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index fd9be60..e430a5e 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -1310,8 +1310,15 @@ export interface SaveSubRoomDataInput { subRoomDataFilename?: string /** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */ subRoomDataHash?: string - /** Uploaded blob key for the room-level METADATA blob (a separate upload). */ + /** + * `RoomData.Filename` — the uploaded ROOM-level metadata blob (a separate upload from the + * scene). It lands on the ROOM as `DataBlob`, not on the subroom: the client's `Room` has + * `DataBlob`/`DataBlobHash` and its `SubRoom` has no field for it (`RoomDataBlob`, where + * this once went, is not a key the client knows, and it dropped it silently). + */ roomDataFilename?: string + /** `RoomData.Hash` — stored beside it as the room's `DataBlobHash`; null when unsent. */ + roomDataHash?: string /** * The save comment — a description of THIS revision, typed into the client's save box. * It belongs to the save (and shows up in the `…/saves` history); it is not the room's @@ -1643,18 +1650,35 @@ export async function saveSubRoomData( }) ) const saveId = Number(save.SubRoomDataSaveId) - if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage - // Nothing here touches the ROOM. A room save is a revision of one SUBROOM, and every - // field it carries describes that revision: `Description` is the save comment shown in - // the `…/saves` history, `PersistenceVersion` and `InventionUsage` describe the scene - // just saved. They used to be copied onto the room as well, which meant each save - // silently replaced the room's public description with the save comment. The room's own - // fields are edited through their own routes (`PUT /rooms/:id/description` and - // friends), so the room row is not rewritten here at all. + // The ONE thing a save writes on the ROOM: `RoomData`, the room-level metadata blob the + // client uploads beside the scene. The client's `Room` reads it as `DataBlob` (with + // `DataBlobHash`), so that is where it goes — a targeted `json_set` of those two keys, + // never a rewrite of the room row. It used to be put on the subroom as `RoomDataBlob`, + // which the client has no field for, so no save ever reached it. + // + // Everything else the save carries describes the SUBROOM's revision: `Description` is the + // save comment shown in the `…/saves` history, `PersistenceVersion` and `InventionUsage` + // describe the scene just saved. They used to be copied onto the room as well, which + // meant each save silently replaced the room's public description with the save comment. + // The room's own fields are edited through their own routes (`PUT /rooms/:id/description` + // and friends). + const roomWrites: D1PreparedStatement[] = [] + if (input.roomDataFilename) { + const hash = input.roomDataHash ?? null + room.DataBlob = input.roomDataFilename + room.DataBlobHash = hash + roomWrites.push( + db + .prepare( + "UPDATE room SET data = json_set(data, '$.DataBlob', ?2, '$.DataBlobHash', ?3) WHERE room_id = ?1" + ) + .bind(roomId, input.roomDataFilename, hash) + ) + } // Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is // the player's own private space with no publish step in the client, so staging one @@ -1662,6 +1686,7 @@ export async function saveSubRoomData( // `publish_save`. One round trip for the rest of the save. const publishNow = input.autoPublish === true || room.IsDorm === true await db.batch([ + ...roomWrites, publishNow ? db .prepare(