migrate subrooms to own storage

This commit is contained in:
Devin Zuczek
2026-07-24 16:08:30 -04:00
parent 5ed9e765a5
commit 568717bb53
7 changed files with 382 additions and 106 deletions
+55
View File
@@ -0,0 +1,55 @@
-- Subrooms as first-class entities. In the original game a subroom has its own
-- globally-unique, autoincrementing `SubRoomId` (minted from a single sequence, not
-- per-room), so they can't live inside the room JSON blob — cloning/dorm creation
-- would otherwise reuse ids and collide across rooms. This moves them into their own
-- `subroom` table keyed by an AUTOINCREMENT `sub_room_id`, backfills from each room's
-- embedded `SubRooms`, and drops `SubRooms` from the room blob. Rooms re-embed their
-- `SubRooms` array on read. Generated from packages/domain/src/rooms-db.ts
-- (SUBROOM_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS subroom (
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id);
-- Backfill. The live blob already contains collisions (the bug this migration fixes:
-- every dorm carries SubRoomId 1, clones reuse per-room ids), so we can't preserve all
-- ids. We keep the FIRST occurrence of each distinct SubRoomId at its original id (so the
-- seeded rooms keep their canonical ids) and mint fresh globally-unique ids for the rest.
-- 1) First occurrence of each non-null SubRoomId keeps its id (lowest room_id wins).
INSERT INTO subroom (sub_room_id, room_id, data)
SELECT sid, room_id, data FROM (
SELECT
CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid,
r.room_id AS room_id,
je.value AS data,
ROW_NUMBER() OVER (
PARTITION BY json_extract(je.value, '$.SubRoomId')
ORDER BY r.room_id
) AS rn
FROM room r, json_each(r.data, '$.SubRooms') je
)
WHERE rn = 1 AND sid IS NOT NULL;
-- 2) Everything else (the duplicates, and any null ids) gets a fresh autoincrement id.
-- Inserting the explicit ids above advanced sqlite_sequence, so these continue past
-- the highest kept id and never collide.
INSERT INTO subroom (room_id, data)
SELECT room_id, data FROM (
SELECT
CAST(json_extract(je.value, '$.SubRoomId') AS INTEGER) AS sid,
r.room_id AS room_id,
je.value AS data,
ROW_NUMBER() OVER (
PARTITION BY json_extract(je.value, '$.SubRoomId')
ORDER BY r.room_id
) AS rn
FROM room r, json_each(r.data, '$.SubRooms') je
)
WHERE NOT (rn = 1 AND sid IS NOT NULL);
-- Single source of truth: drop the now-migrated SubRooms array from the room blob.
UPDATE room SET data = json_remove(data, '$.SubRooms');
+29 -2
View File
@@ -9,6 +9,8 @@ import {
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import importRooms from '../../../static/ImportRooms.json'
@@ -50,11 +52,12 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (read by the photon access-token handler).
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r))))
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
})
describe('rooms endpoints', () => {
@@ -1358,4 +1361,28 @@ describe('rooms endpoints', () => {
).json()) as { SubRoomId: number }
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId)
})
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
// The old per-room `max(SubRoomId)+1` allocator would mint id 3 for room 2's clone —
// colliding with another room that already owns subroom 3. The subroom table's
// autoincrement mints an id above every existing subroom instead.
const maxBefore = (await env.DB.prepare('SELECT MAX(sub_room_id) AS m FROM subroom').first<{
m: number
}>())!.m
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } }
// Above every prior subroom id — a fresh global id, not a per-room collision.
expect(body.value.SubRoomId).toBeGreaterThan(maxBefore)
expect(body.value.RoomId).toBe(2)
// The id is unique across the whole table (exactly one row owns it).
const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1')
.bind(body.value.SubRoomId)
.first<{ n: number }>())!.n
expect(dupes).toBe(1)
})
})