From 9c8bf7087c4940dc159ee2c9b07f1fcc1b2e0c5e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 28 Jul 2026 23:13:35 -0400 Subject: [PATCH] fixup autopublish --- CLAUDE.md | 26 ++- apps/match/src/match.app.ts | 4 + apps/match/src/test/integration/api.test.ts | 53 +++++ apps/rooms/src/openapi.ts | 22 +- apps/rooms/src/rooms.app.ts | 122 ++++++++--- apps/rooms/src/test/integration/api.test.ts | 220 +++++++++++++------- packages/domain/src/rooms-db.ts | 120 +++++++++-- 7 files changed, 432 insertions(+), 135 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fb8d81f..a3319ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,14 +74,30 @@ inconsistency here without checking the client first. `{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old clubhouse on screen until it answered the full details envelope. - Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`, - `/subrooms/:sid/accessibility`) answers `{ success, error, value }` with the whole - 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. + `/subrooms/:sid/accessibility`, `/subrooms/:sid/data`, `/subrooms/:sid/publish_save`) + answers `{ success, error, value }` with the whole updated ROOM — the client re-renders + the room from `value`. Notably `value` is the room even for `clone`, whose product is a + new SUBROOM, and even for `data`, whose product is a SAVE; only the room-level + `POST /rooms/:id/clone` returns the thing it created. A bare subroom from `data` leaves + the old scene on screen even though the save landed — the symptom is "saved fine in the + DB, didn't update visually". - 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 + nothing. The key must be present (null before the first publish); read it via `subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way. +- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says + `AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves + `CurrentSave` alone, so players keep loading the last published version until the owner + posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=`. DORMS always + publish: no publish step exists in the client for them. Saves live in the + `subroom_save` table with globally-unique ids (a bare id has to resolve — + `StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so + `…/saves` is real history and `publish_save` doubles as restore-a-save. +- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED + `CurrentSave` blob, creator included. Joining a private instance, the client itself asks + the owner whether to load the latest or the published version and resolves it from the + `/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make + this server-side: it would put two people in one instance on different versions. - 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/match/src/match.app.ts b/apps/match/src/match.app.ts index 41b690b..5c246ee 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -363,6 +363,10 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) { roomId: num(room.RoomId, 1), subRoomId: num(sub?.SubRoomId, 1), location: str(sub?.UnitySceneId), + // Always the PUBLISHED save. A creator who wants their unpublished work is offered + // the choice client-side from the `/subrooms/{id}/saves` list — matchmaking is not + // involved, and serving a staged blob here would put two people in one instance on + // different versions. dataBlob: subRoomDataBlob(sub), name, maxCapacity: num(sub?.MaxPlayers, 4), diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1ae365a..591e3e3 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -351,6 +351,59 @@ describe('public endpoints', () => { expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE }) }) + test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => { + // The client offers the owner "latest or published" itself, from the + // `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to + // the creator here would put them on a different version to everyone else in the + // same instance. + const room = { + RoomId: 78, + Name: 'StagedRoom', + IsDorm: false, + Accessibility: 1, + CreatorAccountId: 400, + Roles: [{ AccountId: 401, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], + SubRooms: [ + { + SubRoomId: 36, + UnitySceneId: RECCENTER_SCENE, + MaxPlayers: 10, + // Seeded as the published save (seedRoomWithSubRooms mirrors the backfill). + CurrentSave: { DataBlob: 'published.room' }, + }, + ], + } + await seedRoomWithSubRooms(env.DB, room as unknown as Record) + // Stage a newer save the creator hasn't published. + const staged = await env.DB.prepare( + 'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id' + ) + .bind(36, JSON.stringify({ DataBlob: 'staged.room' })) + .first<{ sub_room_data_save_id: number }>() + await env.DB.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1') + .bind(36, staged!.sub_room_data_save_id) + .run() + + const matchmake = async (sub: string) => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/78/36`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'JoinMode=0', + }) + expect(res.status).toBe(200) + return ((await res.json()) as { roomInstance: { dataBlob: string } }).roomInstance + } + + // Creator, co-owner and ordinary player all land on the same published version — + // having a newer staged save changes nothing here. + expect((await matchmake('400')).dataBlob).toBe('published.room') + expect((await matchmake('401')).dataBlob).toBe('published.room') + expect((await matchmake('402')).dataBlob).toBe('published.room') + }) + test('POST /matchmake/club/:clubId places members into the clubhouse', async () => { const matchmake = async (path: string, sub?: string) => exports.default.fetch(`${ORIGIN}${path}`, { diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 0dcb0ba..5b013e4 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -351,13 +351,6 @@ export const RoomEnvelope = z.object({ value: RoomDto.nullable(), }) -/** - * What a room save answers: the saved subroom on success (no envelope — the client - * deserializes the body directly as the subroom), or the PascalCase result envelope when - * the room or subroom doesn't exist. Both at HTTP 200. - */ -export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope]) - /** The 401 the envelope-returning routes answer with — the only one that isn’t HTTP 200. */ export const UNAUTHORIZED_ENVELOPE = json( z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }), @@ -447,6 +440,14 @@ export const SubRoomAccessibilityRequest = z.object({ ), }) +/** + * `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live. + * Any id from the subroom's history works, so this is both publish and restore. + */ +export const PublishSaveRequest = z.object({ + subRoomDataSaveId: z.string().describe('The `SubRoomDataSaveId` to make live'), +}) + /** `POST /rooms/{roomId}/subrooms`. */ export const CreateSubRoomRequest = z.object({ name: z.string().describe('The new subroom’s name'), @@ -477,9 +478,14 @@ export const SaveSubRoomDataRequest = z.object({ .object({ Filename: z.string() }) .optional() .describe('The uploaded room-level data blob — becomes `RoomDataBlob`'), - Description: z.string().optional().describe('Written to the ROOM, not the subroom'), + Description: z.string().optional().describe('The save comment; also written to the ROOM'), PersistenceVersion: z.int().optional(), InventionUsage: z.string().optional().describe('Written to the room'), + UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'), + AutoPublish: z + .boolean() + .optional() + .describe('True publishes the save immediately; otherwise it is staged'), }) /** diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 32ac9e4..6cd6106 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -28,6 +28,7 @@ import { getSubRoomSaves, getVisitedRooms, modifySubRoom, + publishSubRoomSave, removeCheer, removeFavorite, saveSubRoomData, @@ -66,6 +67,7 @@ import { pageParams, PhotonAccessTokenDto, PlayerDataDto, + PublishSaveRequest, RestrictionsRequest, RoleRequest, RoomDto, @@ -79,7 +81,6 @@ import { SubRoomAccessibilityRequest, SubRoomDto, subRoomIdParam, - SubRoomSaveResult, SubRoomSavesPage, TagRequest, UNAUTHORIZED_EMPTY, @@ -1506,14 +1507,21 @@ const app = new Hono() tags: ['Subrooms'], summary: 'Save a subroom’s data (room save)', description: [ - 'Points the subroom at the blobs the client has already uploaded through the `storage`', - 'worker and stamps the save; the room-level fields the save carries (`Description`,', + 'Records a save against the subroom from the blobs the client has already uploaded', + 'through the `storage` worker; the room-level fields it carries (`Description`,', '`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the', 'room’s creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,', 'unlike the other room writes.', '', - 'The push notification carries the whole room, but the RESPONSE is the saved SUBROOM', - 'itself with no envelope — the client deserializes the body directly as the subroom.', + '`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it', + 'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so', + 'players keep loading the last published version until the owner calls', + '`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no', + 'publish step in the client, so staging one would hide the player’s own edits.', + '', + 'Answers the whole updated ROOM in the `{ success, error, value }` envelope, like', + 'every other subroom mutation — the client re-renders from `value`, and a bare', + 'subroom leaves the old scene on screen even though the save landed.', 'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the', 'saver’s id here, because the client NREs on a null one.', ].join('\n'), @@ -1521,10 +1529,7 @@ const app = new Hono() parameters: [roomIdParam, subRoomIdParam], requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'), responses: { - 200: json( - SubRoomSaveResult, - 'The saved subroom, or the result envelope when the room/subroom is unknown' - ), + 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 401: UNAUTHORIZED_EMPTY, 403: FORBIDDEN_RESPONSE, }, @@ -1537,21 +1542,14 @@ const app = new Hono() const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) const room = await getRoomById(c.env.DB, roomId) - if (!room) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.DoesntExist', - Error: 'This room does not exist!', - }) - } + if (!room) return roomEnvelope(c, null, 'This room does not exist!') // A valid token but not the room's owner/co-owner → 403 (the auth gate above // 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. + // metadata blob. `OwnershipProof` is accepted and ignored. const body = (await c.req.json().catch(() => ({}))) as { RoomData?: { Filename?: string } SubRoomData?: { Filename?: string } @@ -1559,29 +1557,27 @@ const app = new Hono() Description?: string PersistenceVersion?: number InventionUsage?: string + AutoPublish?: boolean } 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, + autoPublish: body.AutoPublish === true, description: typeof body.Description === 'string' ? body.Description : undefined, persistenceVersion: typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined, inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined, }) - if (!updated) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.DoesntExist', - Error: 'This room does not exist!', - }) - } + if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!') - // RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM - // itself — no envelope. The client deserializes the body directly as the subroom. + // The whole updated ROOM, like every other subroom mutation — the client + // re-renders the room from `value` and does NOT pick up a bare subroom, so + // answering with just the saved subroom leaves the old scene on screen even + // though the save landed. await pushRoomUpdate(c, accountId, updated) - return c.json(findSubRoom(updated, subRoomId) ?? {}) + return roomEnvelope(c, updated) } ) @@ -1670,6 +1666,76 @@ const app = new Hono() } ) + // Publish a subroom's staged save — promote it to the live one players load. Every + // non-dorm room save only STAGES (see the save route), so this is the manual step that + // makes edits visible. Auth-gated (401) and creator-only: co-owners may save, but + // only the room's owner decides what goes live. Answers the updated ROOM in the + // `{ success, error, value }` envelope, like the other subroom mutations. + .post( + '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/publish_save', + describeRoute({ + tags: ['Subrooms'], + summary: 'Publish one of a subroom’s saves', + description: [ + 'Makes the save named by the `subRoomDataSaveId` form field the one players load —', + 'it becomes the subroom’s `CurrentSave`. A room save only STAGES (dorms excepted),', + 'so nothing a creator saves reaches players until this is called.', + '', + 'The id may be any save in the subroom’s history, so this doubles as restore-a-save.', + '`StagedSubRoomDataSaveId` is cleared only when the published save IS the staged', + 'one — restoring an older version keeps newer unpublished work staged.', + '', + 'Owner-only: co-owners may save but not decide what goes live. A save id belonging', + 'to another subroom is rejected.', + ].join(' '), + security: AUTHED, + parameters: [roomIdParam, subRoomIdParam], + requestBody: form(PublishSaveRequest, 'The save to publish'), + responses: { + 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), + 401: UNAUTHORIZED_ENVELOPE, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) { + return c.json({ success: false, error: 'Unauthorized', value: null }, 401) + } + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + if (room.CreatorAccountId !== accountId) { + return roomEnvelope(c, null, 'You are not the owner of this room!') + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const saveId = + typeof body.subRoomDataSaveId === 'string' + ? Number.parseInt(body.subRoomDataSaveId, 10) + : Number.NaN + if (Number.isNaN(saveId)) { + return roomEnvelope(c, null, 'You must provide a valid save!') + } + + const result = await publishSubRoomSave(c.env.DB, roomId, subRoomId, saveId) + if (!result.ok) { + return roomEnvelope( + c, + null, + result.reason === 'unknown_save' + ? 'That save does not exist!' + : 'This subroom does not exist!' + ) + } + + await pushRoomUpdate(c, accountId, result.room) + return roomEnvelope(c, result.room) + } + ) + // Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of // the subroom `modify` call, but this is what the client actually calls when the // player flips one subroom's visibility, and the body carries the enum NAME diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 728f4f1..43e3e07 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -975,47 +975,60 @@ describe('rooms endpoints', () => { // A valid token but no role on the room → 403. expect((await authed(2, 2, '999')).status).toBe(403) - // The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope. - // Unknown room → DoesntExist. - expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({ - Success: false, - ErrorId: 'Rooms.DoesntExist', + // Rejections use the same lowercase envelope as the success case. + expect(await envOf(await authed(99999, 2, '1'))).toMatchObject({ + success: false, + error: 'This room does not exist!', }) + expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false }) - // Owner saves → 200 with the saved SUBROOM as the bare body (no envelope), - // carrying the new blobs and populated creator. + // Owner saves → 200 with the whole updated ROOM in the envelope. A bare subroom + // here leaves the client showing the old scene even though the save landed. This + // fixture sends `AutoPublish: true`, so the save goes live immediately. const ok = await authed(2, 2, '1') expect(ok.status).toBe(200) - expect(await bodyOf(ok)).toMatchObject({ - SubRoomId: 2, + const saved = await envOf(ok) + expect(saved.success).toBe(true) + expect(saved.value).toMatchObject({ RoomId: 2, Description: 'mydescription here' }) + // The saved subroom rides along inside the room's SubRooms, carrying the new save. + const savedSub = (saved.value!.SubRooms as Array>).find( + (s) => s.SubRoomId === 2 + )! + expect(savedSub).toMatchObject({ 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: [], - }, + }) + expect(savedSub.CurrentSave).toMatchObject({ + DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', }) - // It also persists — the GET returns the subroom with the new save + creator. + // It also persists — the GET returns the subroom with the save live. const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as { SubRoomId: number CreatorAccountId: number - CurrentSave: { DataBlob: string; SubRoomDataSaveId: number } + CurrentSave: { + DataBlob: string + SubRoomDataSaveId: number + SavedByAccountId: number + PersistenceVersion: number + UnitySubAssets: unknown[] + Tags: unknown[] + } + StagedSubRoomDataSaveId: number | null } - expect(sub).toMatchObject({ - SubRoomId: 2, - CreatorAccountId: 1, - CurrentSave: { DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f' }, + expect(sub).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 }) + expect(sub.CurrentSave).toMatchObject({ + DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', + SavedByAccountId: 1, + PersistenceVersion: 41, + UnitySubAssets: [], + Tags: [], }) expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0) + expect(sub.StagedSubRoomDataSaveId).toBeNull() + + // Room-level fields land on the room too. const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string PersistenceVersion: number @@ -1024,10 +1037,14 @@ describe('rooms endpoints', () => { expect(room.PersistenceVersion).toBe(41) // A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200 - // with the subroom body. The creator stays account 1 (not clobbered). + // with the room envelope. The creator stays account 1 (not clobbered). const coOwner = await authed(2, 2, '2') expect(coOwner.status).toBe(200) - expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 }) + const coOwnerEnv = await envOf(coOwner) + expect(coOwnerEnv.success).toBe(true) + expect( + (coOwnerEnv.value!.SubRooms as Array>).find((s) => s.SubRoomId === 2) + ).toMatchObject({ CreatorAccountId: 1 }) }) it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => { @@ -1045,31 +1062,55 @@ describe('rooms endpoints', () => { } }) - it('a real client room-save body populates CurrentSave and comes back on GET /rooms/:id', async () => { + it('a real client room-save body stages, and publish_save makes it live', async () => { + const save = async (body: unknown) => + SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify(body), + }) + type Sub = { + CurrentSave: Record | null + StagedSubRoomDataSaveId: number | null + } + const subOf = async () => + (await (await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`)).json()) as Sub + const publish = async (saveId: number, sub = '1') => + SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/publish_save`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ subRoomDataSaveId: String(saveId) }).toString(), + }) + // 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, - }), + const res = await save({ + 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({ + // Room 5 is not a dorm → staged, nothing live yet. + const stagedSub = await subOf() + expect(stagedSub.CurrentSave).toBeNull() + const firstId = stagedSub.StagedSubRoomDataSaveId! + expect(firstId).toBeGreaterThan(0) + + // Publishing makes it what the loader fetches, and clears the staging slot. + expect((await publish(firstId)).status).toBe(200) + const live = await subOf() + expect(live.StagedSubRoomDataSaveId).toBeNull() + expect(live.CurrentSave).toMatchObject({ SubRoomId: 5, + SubRoomDataSaveId: firstId, DataBlob: '2026-07-28/f176fc3b-scene', PersistenceVersion: 51, SavedByAccountId: 1, @@ -1080,34 +1121,27 @@ describe('rooms endpoints', () => { }) // 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) + expect('DataBlobHash' in live.CurrentSave!).toBe(false) + expect('UnityAssetId' in live.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 + // It's on the room read too — that's what the loader actually fetches. + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as { + SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomDataSaveId: number } | null }> } - expect(saves.TotalResults).toBe(1) - expect(saves.Results[0]!.DataBlob).toBe('2026-07-28/f176fc3b-scene') + expect(room.SubRooms.find((s) => s.SubRoomId === 5)!.CurrentSave!.SubRoomDataSaveId).toBe( + firstId + ) - // 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') + // A second save appends and stages — what players load does NOT change. + await save({ SubRoomData: { Filename: 'second.room' } }) + const afterSecond = await subOf() + const secondId = afterSecond.StagedSubRoomDataSaveId! + expect(secondId).toBeGreaterThan(firstId) + expect(afterSecond.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId }) // 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 }> + Results: Array<{ DataBlob: string; Description: string }> TotalResults: number } expect(history.TotalResults).toBe(2) @@ -1116,7 +1150,29 @@ describe('rooms endpoints', () => { '2026-07-28/f176fc3b-scene', ]) // A save with no Description records an empty string, not null. - expect(after.CurrentSave.Description).toBe('') + expect(history.Results[0]!.Description).toBe('') + + // Publishing an OLDER save is a restore — and keeps the newer staged work. + expect((await publish(secondId)).status).toBe(200) + expect((await subOf()).StagedSubRoomDataSaveId).toBeNull() + expect((await publish(firstId)).status).toBe(200) + const restored = await subOf() + expect(restored.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId }) + + // A save id from a different subroom is rejected, even though ids are global. + const foreign = (await ( + await publish( + ((await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as Sub).CurrentSave! + .SubRoomDataSaveId as number + ) + ).json()) as { success: boolean; error: string } + expect(foreign.success).toBe(false) + expect(foreign.error).toBe('That save does not exist!') + + // Co-owners may save but not publish. + expect(((await (await publish(firstId, '2')).json()) as { success: boolean }).success).toBe( + false + ) }) it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => { @@ -1162,6 +1218,25 @@ describe('rooms endpoints', () => { expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId) }) + it('a dorm save publishes immediately instead of staging', async () => { + // Room 1 is the seeded DormRoom (IsDorm). A dorm is the player's own space with no + // publish step in the client, so staging one would make their edits permanently + // invisible — dorm saves go straight live. + const res = await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, + body: JSON.stringify({ SubRoomData: { Filename: 'dorm.room' } }), + }) + expect(res.status).toBe(200) + + const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`)).json()) as { + CurrentSave: { DataBlob: string } | null + StagedSubRoomDataSaveId: number | null + } + expect(sub.CurrentSave).toMatchObject({ DataBlob: 'dorm.room' }) + expect(sub.StagedSubRoomDataSaveId).toBeNull() + }) + 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. @@ -1171,10 +1246,10 @@ describe('rooms endpoints', () => { headers: { 'Content-Type': 'application/json', ...(await bearer('1')) }, body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }), }) + // Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId. 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 + return ((await res.json()) as { StagedSubRoomDataSaveId: number }).StagedSubRoomDataSaveId } // Two different subrooms, each getting their FIRST save. @@ -1825,6 +1900,7 @@ describe('rooms endpoints', () => { 'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms/{subRoomId}/clone', 'POST /rooms/{roomId}/subrooms/{subRoomId}/data', + 'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save', 'PUT /rooms/{roomId}/accessibility', 'PUT /rooms/{roomId}/cloning', 'PUT /rooms/{roomId}/description', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index b33a41e..2082e70 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -319,6 +319,12 @@ export interface SaveSubRoomDataInput { inventionUsage?: string /** Optional baked-asset id; emitted on the save only when present. */ unityAssetId?: string + /** + * The client's `AutoPublish`. True publishes the save outright (the author wants it + * live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and + * always publish. + */ + autoPublish?: boolean } /** @@ -416,11 +422,15 @@ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null { } /** - * 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 (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. + * Persist a room-save against a specific subroom and record the room-level fields the + * save carries. Returns the updated (hydrated) room, or null when the room or subroom + * doesn't exist. + * + * Whether the save goes live is the client's call: `AutoPublish: true` publishes it + * outright, otherwise it becomes the subroom's `staged_save_id` with the live + * `current_save_id` untouched, so what players load doesn't change until the room's + * creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have + * no publish flow in the client. */ export async function saveSubRoomData( db: D1Database, @@ -431,20 +441,30 @@ export async function saveSubRoomData( ): Promise { const room = await getRoomById(db, roomId) if (!room) return null - const sub = await getSubRoom(db, roomId, subRoomId) + // Read off the already-hydrated room rather than re-querying the subroom and its + // save — getRoomById has both, and this path is write-heavy enough already. + const sub = findSubRoom(room, subRoomId) if (!sub) return null // Populate the subroom's creator on first save — it starts null, and the // client NREs on a null CreatorAccountId. Only the owner reaches this path. if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId - // 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. + // Append a new save row. 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. + // + // A staged save carries forward from the previous STAGED one when there is one, so a + // creator's second edit builds on their first rather than on what's live. + const staged = + typeof sub.StagedSubRoomDataSaveId === 'number' + ? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId) + : null const previous = - sub.CurrentSave && typeof sub.CurrentSave === 'object' + staged ?? + (sub.CurrentSave && typeof sub.CurrentSave === 'object' ? (sub.CurrentSave as SubRoomDataSave) - : undefined + : undefined) const priorVersion = previous?.PersistenceVersion const priorBlob = previous?.DataBlob const save = await insertSubRoomSave( @@ -465,28 +485,82 @@ export async function saveSubRoomData( 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)) - + 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 - 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, serializeRoom(room)) - .run() + + // 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 + // would leave their edits permanently invisible. Otherwise stage it and wait for + // `publish_save`. One round trip for the rest of the save. + const publishNow = input.autoPublish === true || room.IsDorm === true + await db.batch([ + publishNow + ? db + .prepare( + 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1' + ) + .bind(subRoomId, saveId) + : db + .prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1') + .bind(subRoomId, saveId), + db + .prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1') + .bind(subRoomId, serializeSubRoom(sub, roomId)), + db.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1').bind(roomId, serializeRoom(room)), + ]) // Re-hydrate so the returned room reflects the just-saved subroom. return hydrateRoom(db, room) } +/** + * Publish one of a subroom's saves by id: make it the `current_save_id` players load. + * This is the manual step every non-dorm room save waits on ({@link saveSubRoomData} + * only stages). Because it takes an explicit id it doubles as restore-a-save — the id + * can be any save in the subroom's history, not just the staged one. + * + * The staging slot is cleared only when the save being published IS the staged one, so + * restoring an older version doesn't silently discard newer unpublished work. + * + * The id is looked up scoped to the subroom, so one subroom can't publish another's save + * (ids are globally unique, so an unscoped lookup would happily resolve). + * + * Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) / + * `unknown_save` (no such save on this subroom). + */ +export async function publishSubRoomSave( + db: D1Database, + roomId: number, + subRoomId: number, + saveId: number +): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> { + const sub = await getSubRoom(db, roomId, subRoomId) + if (!sub) return { ok: false, reason: 'not_found' } + if (!(await getSubRoomSaveById(db, subRoomId, saveId))) { + return { ok: false, reason: 'unknown_save' } + } + + await db + .prepare( + `UPDATE subroom SET current_save_id = ?2, + staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END + WHERE sub_room_id = ?1` + ) + .bind(subRoomId, saveId) + .run() + + const room = await getRoomById(db, roomId) + if (!room) return { ok: false, reason: 'not_found' } + return { ok: true, room } +} + /** Fields from the client's subroom `modify` form (each applied only when supplied). */ export interface ModifySubRoomInput { name?: string @@ -898,10 +972,12 @@ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise { .run() } -/** Point a subroom at its live/published save. */ +/** Point a subroom at its live/published save, clearing any staged one. */ 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') + .prepare( + 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1' + ) .bind(subRoomId, saveId) .run() }