mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
fixed room and subroom saving, add room saves
This commit is contained in:
@@ -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
|
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
|
`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.
|
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
|
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
setLoginContext,
|
setLoginContext,
|
||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
|
subRoomDataBlob,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
@@ -119,7 +120,7 @@ async function placeNewPlayerInOrientation(
|
|||||||
subRoomId: num(sub?.SubRoomId, 1),
|
subRoomId: num(sub?.SubRoomId, 1),
|
||||||
roomInstanceType: RoomInstanceType.Public,
|
roomInstanceType: RoomInstanceType.Public,
|
||||||
location: str(sub?.UnitySceneId),
|
location: str(sub?.UnitySceneId),
|
||||||
dataBlob: str(sub?.DataBlob),
|
dataBlob: subRoomDataBlob(sub),
|
||||||
eventId: 0,
|
eventId: 0,
|
||||||
clubId: 0,
|
clubId: 0,
|
||||||
roomCode: '',
|
roomCode: '',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
|
subRoomDataBlob,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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
|
// 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.
|
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
EMPTY_OK,
|
EMPTY_OK,
|
||||||
@@ -363,7 +363,7 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
|
|||||||
roomId: num(room.RoomId, 1),
|
roomId: num(room.RoomId, 1),
|
||||||
subRoomId: num(sub?.SubRoomId, 1),
|
subRoomId: num(sub?.SubRoomId, 1),
|
||||||
location: str(sub?.UnitySceneId),
|
location: str(sub?.UnitySceneId),
|
||||||
dataBlob: str(sub?.DataBlob),
|
dataBlob: subRoomDataBlob(sub),
|
||||||
name,
|
name,
|
||||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||||
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
|
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
|
||||||
@@ -693,7 +693,10 @@ const app = new Hono<App>()
|
|||||||
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
|
'`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.',
|
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
|
||||||
].join(' '),
|
].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 },
|
responses: { 200: EMPTY_OK },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -138,14 +138,43 @@ export const LoadScreenDto = z.object({
|
|||||||
Subtitle: z.string(),
|
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
|
* 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
|
* unique, autoincrementing `SubRoomId` (the original game mints them from a single
|
||||||
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read.
|
* 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 —
|
* `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
|
* the client NREs on a null one. `CurrentSave` is null until the first save; the flat
|
||||||
* appear once the subroom has been saved at least once.
|
* `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields are legacy and are NOT what the client
|
||||||
|
* loads from.
|
||||||
*/
|
*/
|
||||||
export const SubRoomDto = z.object({
|
export const SubRoomDto = z.object({
|
||||||
SubRoomId: z.int(),
|
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'),
|
.describe('0 Private, 1 Public, 2 Unlisted, 3 Dev_only, 4 Dev_Unlisted — set independently'),
|
||||||
ShouldAutoStageSaves: z.boolean(),
|
ShouldAutoStageSaves: z.boolean(),
|
||||||
StagedSubRoomDataSaveId: z.int().nullable(),
|
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'),
|
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
|
||||||
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(),
|
||||||
@@ -440,7 +472,7 @@ export const SaveSubRoomDataRequest = z.object({
|
|||||||
SubRoomData: z
|
SubRoomData: z
|
||||||
.object({ Filename: z.string() })
|
.object({ Filename: z.string() })
|
||||||
.optional()
|
.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
|
RoomData: z
|
||||||
.object({ Filename: z.string() })
|
.object({ Filename: z.string() })
|
||||||
.optional()
|
.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.
|
* save history (a save overwrites the subroom's blob inline), so it's always empty.
|
||||||
*/
|
*/
|
||||||
export const SubRoomSavesPage = z.object({
|
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(),
|
TotalResults: z.int(),
|
||||||
|
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Session ---------------------------------------------------------------
|
// ---- Session ---------------------------------------------------------------
|
||||||
|
|||||||
+37
-14
@@ -25,6 +25,7 @@ import {
|
|||||||
getRoomsByCreator,
|
getRoomsByCreator,
|
||||||
getRoomsByIds,
|
getRoomsByIds,
|
||||||
getSimilarRooms,
|
getSimilarRooms,
|
||||||
|
getSubRoomSaves,
|
||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
removeCheer,
|
removeCheer,
|
||||||
@@ -1448,34 +1449,50 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// A subroom's saved-data versions — the room-history / "restore a save" list, paged as
|
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
|
||||||
// PagedResultsDTO<SubRoomDataSaveDTO> (`{ Results, TotalResults }`). We don't keep a save
|
// save is its own `subroom_save` row (nothing is overwritten), so this is real
|
||||||
// history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so
|
// history, newest first, paged by skip/take.
|
||||||
// there are no distinct versions to list — this returns an empty page. The
|
|
||||||
// unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored.
|
|
||||||
.get(
|
.get(
|
||||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Subrooms'],
|
tags: ['Subrooms'],
|
||||||
summary: 'A subroom’s saved-data versions',
|
summary: 'A subroom’s saved-data versions',
|
||||||
description: [
|
description: [
|
||||||
'The room-history / “restore a save” list, paged as',
|
'The room-history / “restore a save” list, newest first. Every room save appends a',
|
||||||
'`PagedResultsDTO<SubRoomDataSaveDTO>`. We keep no save history — a save (`POST',
|
'row rather than overwriting, so this is the subroom’s full history; it is empty',
|
||||||
'…/data`) overwrites the subroom’s current blob inline, so there are no distinct',
|
'only when the subroom has never been saved.',
|
||||||
'versions to list — and this is always an empty page. The',
|
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
|
||||||
'`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params 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(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
roomIdParam,
|
roomIdParam,
|
||||||
subRoomIdParam,
|
subRoomIdParam,
|
||||||
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||||||
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||||||
stringQuery('skip', 'Accepted and ignored — the page is always empty'),
|
stringQuery('skip', 'How many saves to skip (default 0)'),
|
||||||
stringQuery('take', 'Accepted and ignored — the page is always empty'),
|
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
|
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
|
||||||
@@ -1531,9 +1548,14 @@ const app = new Hono<App>()
|
|||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
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:
|
||||||
|
// `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 {
|
const body = (await c.req.json().catch(() => ({}))) as {
|
||||||
RoomData?: { Filename?: string }
|
RoomData?: { Filename?: string }
|
||||||
SubRoomData?: { Filename?: string }
|
SubRoomData?: { Filename?: string }
|
||||||
|
UnityAssetId?: string | null
|
||||||
Description?: string
|
Description?: string
|
||||||
PersistenceVersion?: number
|
PersistenceVersion?: number
|
||||||
InventionUsage?: string
|
InventionUsage?: string
|
||||||
@@ -1542,6 +1564,7 @@ const app = new Hono<App>()
|
|||||||
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
||||||
subRoomDataFilename: body.SubRoomData?.Filename,
|
subRoomDataFilename: body.SubRoomData?.Filename,
|
||||||
roomDataFilename: body.RoomData?.Filename,
|
roomDataFilename: body.RoomData?.Filename,
|
||||||
|
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
|
||||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||||
persistenceVersion:
|
persistenceVersion:
|
||||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
||||||
|
|||||||
@@ -988,23 +988,34 @@ describe('rooms endpoints', () => {
|
|||||||
expect(ok.status).toBe(200)
|
expect(ok.status).toBe(200)
|
||||||
expect(await bodyOf(ok)).toMatchObject({
|
expect(await bodyOf(ok)).toMatchObject({
|
||||||
SubRoomId: 2,
|
SubRoomId: 2,
|
||||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
|
||||||
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
|
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
|
||||||
CreatorAccountId: 1,
|
CreatorAccountId: 1,
|
||||||
PersistenceVersion: 41,
|
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 {
|
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
|
||||||
SubRoomId: number
|
SubRoomId: number
|
||||||
DataBlob: string
|
|
||||||
CreatorAccountId: number
|
CreatorAccountId: number
|
||||||
|
CurrentSave: { DataBlob: string; SubRoomDataSaveId: number }
|
||||||
}
|
}
|
||||||
expect(sub).toMatchObject({
|
expect(sub).toMatchObject({
|
||||||
SubRoomId: 2,
|
SubRoomId: 2,
|
||||||
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
|
|
||||||
CreatorAccountId: 1,
|
CreatorAccountId: 1,
|
||||||
|
CurrentSave: { DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f' },
|
||||||
})
|
})
|
||||||
|
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
|
||||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||||
Description: string
|
Description: string
|
||||||
PersistenceVersion: number
|
PersistenceVersion: number
|
||||||
@@ -1019,6 +1030,180 @@ describe('rooms endpoints', () => {
|
|||||||
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
|
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<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
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<string, unknown> | 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<string, unknown>
|
||||||
|
}
|
||||||
|
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 () => {
|
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
|
||||||
// The lowercase `{ success, error, value }` envelope this endpoint returns.
|
// The lowercase `{ success, error, value }` envelope this endpoint returns.
|
||||||
type TagResult = {
|
type TagResult = {
|
||||||
@@ -1558,12 +1743,35 @@ describe('rooms endpoints', () => {
|
|||||||
expect((await SELF.fetch(`${ORIGIN}/rooms/700/subrooms/900/data`)).status).toBe(200)
|
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 () => {
|
it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => {
|
||||||
const res = await SELF.fetch(
|
type Page = {
|
||||||
`${ORIGIN}/rooms/2/subrooms/2/saves?unityAssetTarget=0&unityAssetVersion=1&skip=0&take=20`
|
Results: Array<{ SubRoomId: number; SubRoomDataSaveId: number }>
|
||||||
)
|
TotalResults: number
|
||||||
expect(res.status).toBe(200)
|
TotalCount: number
|
||||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
}
|
||||||
|
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 () => {
|
it('GET /openapi.json documents every route', async () => {
|
||||||
|
|||||||
+355
-25
@@ -57,9 +57,31 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
|||||||
`CREATE TABLE IF NOT EXISTS subroom (
|
`CREATE TABLE IF NOT EXISTS subroom (
|
||||||
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
room_id INTEGER NOT NULL,
|
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)`,
|
`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). */
|
/** 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. */
|
/** Fields from the client's room-save POST body. */
|
||||||
export interface SaveSubRoomDataInput {
|
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
|
subRoomDataFilename?: string
|
||||||
/** Uploaded blob key for the room-level data. */
|
/** Uploaded blob key for the room-level METADATA blob (a separate upload). */
|
||||||
roomDataFilename?: string
|
roomDataFilename?: string
|
||||||
description?: string
|
description?: string
|
||||||
persistenceVersion?: number
|
persistenceVersion?: number
|
||||||
inventionUsage?: string
|
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<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
// client NREs on a null CreatorAccountId. Only the owner reaches this path.
|
||||||
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
|
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
|
||||||
|
|
||||||
// Point the subroom at the newly-uploaded data blobs and stamp the save.
|
// Append a new save row and publish it. The blob the loader downloads lives on the
|
||||||
if (input.subRoomDataFilename) sub.DataBlob = input.subRoomDataFilename
|
// 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
|
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
|
||||||
@@ -434,7 +581,8 @@ export async function createSubRoom(
|
|||||||
IsSandbox: true,
|
IsSandbox: true,
|
||||||
LastModeratedSaveModerationState: 0,
|
LastModeratedSaveModerationState: 0,
|
||||||
ShouldAutoStageSaves: true,
|
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.
|
// Refresh the hydrated SubRooms so the returned room includes the one just inserted.
|
||||||
await attachSubRooms(db, [room])
|
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.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
|
||||||
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
|
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
|
||||||
|
|
||||||
await db
|
await db.batch([
|
||||||
.prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2')
|
db
|
||||||
.bind(roomId, subRoomId)
|
.prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2')
|
||||||
.run()
|
.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)
|
const room = await getRoomById(db, roomId)
|
||||||
if (!room) return { ok: false, reason: 'not_found' }
|
if (!room) return { ok: false, reason: 'not_found' }
|
||||||
@@ -484,18 +636,41 @@ interface SubRoomRow {
|
|||||||
sub_room_id: number
|
sub_room_id: number
|
||||||
room_id: number
|
room_id: number
|
||||||
data: string
|
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 => ({
|
const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({
|
||||||
...(JSON.parse(row.data) as SubRoom),
|
...(JSON.parse(row.data) as SubRoom),
|
||||||
SubRoomId: row.sub_room_id,
|
SubRoomId: row.sub_room_id,
|
||||||
RoomId: row.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 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 })
|
return JSON.stringify({ ...rest, RoomId: roomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +683,45 @@ const serializeRoom = (room: Room): string => {
|
|||||||
return JSON.stringify(rest)
|
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<void> {
|
||||||
|
const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
|
||||||
|
const byId = new Map<number, SubRoomDataSave>()
|
||||||
|
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<SubRoomSaveRow>()
|
||||||
|
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<SubRoom[]> {
|
||||||
|
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). */
|
/** Attach each room's `SubRooms` array from the subroom table (one batched query). */
|
||||||
async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
||||||
const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n))
|
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<void> {
|
|||||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.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`
|
WHERE room_id IN (${placeholders}) ORDER BY sub_room_id`
|
||||||
)
|
)
|
||||||
.bind(...ids)
|
.bind(...ids)
|
||||||
.all<SubRoomRow>()
|
.all<SubRoomRow>()
|
||||||
|
const subs = await parseSubRoomRows(db, results)
|
||||||
const byRoom = new Map<number, SubRoom[]>()
|
const byRoom = new Map<number, SubRoom[]>()
|
||||||
for (const r of results) {
|
results.forEach((r, i) => {
|
||||||
const list = byRoom.get(r.room_id) ?? []
|
const list = byRoom.get(r.room_id) ?? []
|
||||||
list.push(parseSubRoomRow(r))
|
list.push(subs[i]!)
|
||||||
byRoom.set(r.room_id, list)
|
byRoom.set(r.room_id, list)
|
||||||
}
|
})
|
||||||
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,19 +766,96 @@ export async function getSubRoom(
|
|||||||
subRoomId: number
|
subRoomId: number
|
||||||
): Promise<SubRoom | null> {
|
): Promise<SubRoom | null> {
|
||||||
const row = await db
|
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)
|
.bind(roomId, subRoomId)
|
||||||
.first<SubRoomRow>()
|
.first<SubRoomRow>()
|
||||||
return row ? parseSubRoomRow(row) : null
|
if (!row) return null
|
||||||
|
return (await parseSubRoomRows(db, [row]))[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All of a room's subrooms, ordered by SubRoomId. */
|
/** All of a room's subrooms, ordered by SubRoomId. */
|
||||||
export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> {
|
export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> {
|
||||||
const { results } = await db
|
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)
|
.bind(roomId)
|
||||||
.all<SubRoomRow>()
|
.all<SubRoomRow>()
|
||||||
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<SubRoomDataSave> {
|
||||||
|
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<SubRoomDataSave[]> {
|
||||||
|
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<SubRoomSaveRow>()
|
||||||
|
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<SubRoomDataSave | null> {
|
||||||
|
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<SubRoomSaveRow>()
|
||||||
|
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')
|
.prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id')
|
||||||
.bind(roomId, serializeSubRoom(sub, roomId))
|
.bind(roomId, serializeSubRoom(sub, roomId))
|
||||||
.first<{ sub_room_id: number }>()
|
.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. */
|
/** Overwrite a subroom's stored data blob in place. */
|
||||||
@@ -590,20 +898,35 @@ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise<void> {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Point a subroom at its live/published save. */
|
||||||
|
async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise<void> {
|
||||||
|
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
|
* 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.
|
* blob) and each embedded subroom into the `subroom` table, preserving explicit ids. Any
|
||||||
* Used by the migration's data model in tests (mirrors 0007_subrooms.sql's backfill).
|
* 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<void> {
|
export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<void> {
|
||||||
const roomId = Number(room.RoomId)
|
const roomId = Number(room.RoomId)
|
||||||
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
|
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()
|
await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
|
||||||
for (const sub of subRooms) {
|
for (const sub of subRooms) {
|
||||||
|
const subRoomId = Number(sub.SubRoomId)
|
||||||
await db
|
await db
|
||||||
.prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)')
|
.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()
|
.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<void>
|
|||||||
await db.batch([
|
await db.batch([
|
||||||
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
||||||
db.prepare('DELETE FROM interaction 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),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user