[rooms] fix cloud variables

This commit is contained in:
Devin Zuczek
2026-10-03 21:53:23 -04:00
parent ec423b2eb9
commit f7167358d8
6 changed files with 74 additions and 21 deletions
+13 -3
View File
@@ -255,7 +255,10 @@ export const SubRoomDto = z.object({
'The latest room save — where the client finds the scene blob. Null until first save' '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`'), 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'), DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
PersistenceVersion: z.int().optional(), PersistenceVersion: z.int().optional(),
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'), 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(), PublishedAt: z.string(),
BecameRRStudioRoomAt: z.string().nullable(), BecameRRStudioRoomAt: z.string().nullable(),
Stats: RoomStatsDto, 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 BoostCount: z
.int() .int()
.describe('Boosts on the room. Nothing grants boosts here, so always 0 — but present'), .describe('Boosts on the room. Nothing grants boosts here, so always 0 — but present'),
@@ -877,9 +885,11 @@ export const SaveSubRoomDataRequest = z.object({
.optional() .optional()
.describe('The uploaded scene-data blob — becomes the subroom’s `CurrentSave.DataBlob`'), .describe('The uploaded scene-data blob — becomes the subroom’s `CurrentSave.DataBlob`'),
RoomData: z RoomData: z
.object({ Filename: z.string() }) .object({ Filename: z.string(), Hash: z.string().nullable().optional() })
.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 Description: z
.string() .string()
.optional() .optional()
+5 -3
View File
@@ -3606,10 +3606,11 @@ const app = new Hono<App>()
if (!canManageRoom(room, accountId)) return c.body(null, 403) if (!canManageRoom(room, accountId)) return c.body(null, 403)
// The client uploads BOTH blobs to `storage` first and sends their keys here: // The client uploads BOTH blobs to `storage` first and sends their keys here:
// `SubRoomData` is the scene blob (what the loader downloads), `RoomData` the // `SubRoomData` is the scene blob (what the loader downloads, onto the subroom's
// metadata blob. `OwnershipProof` is accepted and ignored. // 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 { const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string } RoomData?: { Filename?: string; Hash?: string | null }
SubRoomData?: { Filename?: string; Hash?: string | null } SubRoomData?: { Filename?: string; Hash?: string | null }
UnityAssetId?: string | null UnityAssetId?: string | null
Description?: string Description?: string
@@ -3623,6 +3624,7 @@ const app = new Hono<App>()
subRoomDataHash: subRoomDataHash:
typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined, typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined,
roomDataFilename: body.RoomData?.Filename, roomDataFilename: body.RoomData?.Filename,
roomDataHash: typeof body.RoomData?.Hash === 'string' ? body.RoomData.Hash : undefined,
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined, unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
autoPublish: body.AutoPublish === true, autoPublish: body.AutoPublish === true,
description: typeof body.Description === 'string' ? body.Description : undefined, description: typeof body.Description === 'string' ? body.Description : undefined,
+11 -1
View File
@@ -3552,10 +3552,20 @@ describe('rooms endpoints', () => {
(s) => s.SubRoomId === 2 (s) => s.SubRoomId === 2
)! )!
expect(savedSub).toMatchObject({ expect(savedSub).toMatchObject({
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
CreatorAccountId: 1, CreatorAccountId: 1,
PersistenceVersion: 41, 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({ expect(savedSub.CurrentSave).toMatchObject({
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
}) })
+7 -2
View File
@@ -37,7 +37,12 @@ import type { App } from './context'
* *
* RoomSave (1) lands under `room/` (not `roomsave/`) so the `cdn` worker's * RoomSave (1) lands under `room/` (not `roomsave/`) so the `cdn` worker's
* `GET /room/:dataBlob` route serves the blob back — both bind the same * `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/<name>` 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<number, string> = { const UPLOAD_SUBFOLDER: Record<number, string> = {
1: 'room', 1: 'room',
@@ -45,7 +50,7 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
3: 'image', 3: 'image',
4: 'video', 4: 'video',
5: 'invention', 5: 'invention',
6: 'roommetadata', 6: 'room',
} }
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */ /** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -63,7 +63,7 @@ it('POST /upload 401s without a token', async () => {
expect(res.status).toBe(401) 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=<binary>. // Mirrors the client's multipart upload: FileType=6, File=<binary>.
const bytes = new Uint8Array([0x10, 0x02, 0x1a, 0x00]) const bytes = new Uint8Array([0x10, 0x02, 0x1a, 0x00])
const res = await SELF.fetch(`${ORIGIN}/upload`, { 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}$/ /^\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. // The bytes are persisted in the shared CDN bucket under `room/`, the same folder as a
const stored = await env.CDN_ASSETS.get(`roommetadata/${filename}`) // scene blob: the client downloads the room's `DataBlob` from the cdn's `/room/<name>`.
const stored = await env.CDN_ASSETS.get(`room/${filename}`)
expect(stored).not.toBeNull() expect(stored).not.toBeNull()
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes) expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
}) })
+34 -9
View File
@@ -1310,8 +1310,15 @@ export interface SaveSubRoomDataInput {
subRoomDataFilename?: string subRoomDataFilename?: string
/** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */ /** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */
subRoomDataHash?: string 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 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. * 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 * 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) const saveId = Number(save.SubRoomDataSaveId)
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
sub.DataSavedAt = new Date().toISOString() sub.DataSavedAt = new Date().toISOString()
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage
// Nothing here touches the ROOM. A room save is a revision of one SUBROOM, and every // The ONE thing a save writes on the ROOM: `RoomData`, the room-level metadata blob the
// field it carries describes that revision: `Description` is the save comment shown in // client uploads beside the scene. The client's `Room` reads it as `DataBlob` (with
// the `…/saves` history, `PersistenceVersion` and `InventionUsage` describe the scene // `DataBlobHash`), so that is where it goes — a targeted `json_set` of those two keys,
// just saved. They used to be copied onto the room as well, which meant each save // never a rewrite of the room row. It used to be put on the subroom as `RoomDataBlob`,
// silently replaced the room's public description with the save comment. The room's own // which the client has no field for, so no save ever reached it.
// fields are edited through their own routes (`PUT /rooms/:id/description` and //
// friends), so the room row is not rewritten here at all. // 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 // 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 // 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. // `publish_save`. One round trip for the rest of the save.
const publishNow = input.autoPublish === true || room.IsDorm === true const publishNow = input.autoPublish === true || room.IsDorm === true
await db.batch([ await db.batch([
...roomWrites,
publishNow publishNow
? db ? db
.prepare( .prepare(