From 7686bf7bf6701e012c24bb31d8284523ba2e64bc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 9 Jul 2026 20:57:29 -0400 Subject: [PATCH] move rooms schema to package --- .gitignore | 2 + apps/accounts/src/accounts.app.ts | 2 +- apps/api/package.json | 1 + apps/api/src/rooms-db.ts | 22 ---- apps/api/src/routes/rooms.ts | 3 +- apps/match/src/match.app.ts | 10 +- apps/match/src/rooms-db.ts | 100 ---------------- apps/rooms/package.json | 1 + apps/rooms/src/rooms.app.ts | 7 +- apps/rooms/src/test/integration/api.test.ts | 5 +- packages/domain/src/index.ts | 1 + .../rooms => packages/domain}/src/rooms-db.ts | 108 +++++++++++++++--- pnpm-lock.yaml | 6 + 13 files changed, 117 insertions(+), 151 deletions(-) delete mode 100644 apps/api/src/rooms-db.ts delete mode 100644 apps/match/src/rooms-db.ts rename {apps/rooms => packages/domain}/src/rooms-db.ts (85%) diff --git a/.gitignore b/.gitignore index 96672eb..858ffaa 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ yarn-error.log* # Agents .claude/settings.local.json + +.idea diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index fa9ae22..5814dbd 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -49,7 +49,7 @@ function unauthorized(c: Context) { } /** Username changes a fresh account starts with (until one has been consumed). */ -const DEFAULT_USERNAME_CHANGES = 1 +const DEFAULT_USERNAME_CHANGES = 5 /** * Username-change result envelope: `{ success, error, value }`, always HTTP 200. diff --git a/apps/api/package.json b/apps/api/package.json index f196077..a6262bb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "hono": "4.12.27", diff --git a/apps/api/src/rooms-db.ts b/apps/api/src/rooms-db.ts deleted file mode 100644 index ee4dd13..0000000 --- a/apps/api/src/rooms-db.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Read helpers for the shared `recflare` D1 database. The schema, migrations, - * and seed are owned by the `rooms` worker (apps/rooms/src/rooms-db.ts + - * migrations); this worker binds the same database read-only to resolve room - * roles for the `/api/rooms/v1/verifyRole` endpoint. Keep these queries in sync - * with the rooms worker's. - */ - -/** A stored room — the parsed JSON blob (full client-facing room response). */ -export type Room = Record - -interface RoomRow { - data: string -} - -const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) - -export async function getRoomById(db: D1Database, roomId: number): Promise { - return parseOne( - await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first() - ) -} diff --git a/apps/api/src/routes/rooms.ts b/apps/api/src/routes/rooms.ts index da11e56..439ca73 100644 --- a/apps/api/src/routes/rooms.ts +++ b/apps/api/src/routes/rooms.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono' +import { getRoomById } from '@repo/domain' + import { authedId } from '../http' -import { getRoomById } from '../rooms-db' import type { App } from '../context' diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 6104394..9674286 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -1,7 +1,12 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { RoomInstanceType } from '@repo/domain' +import { + getOrCreateDormRoom, + getRoomById, + getRoomByName, + RoomInstanceType, +} from '@repo/domain' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' @@ -11,11 +16,10 @@ import { getRoomInstancesByRoom, setRoomInstanceInProgress, } from './room-instance-db' -import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db' +import type { Room } from '@repo/domain' import type { Context } from 'hono' import type { App } from './context' -import type { Room } from './rooms-db' /** * The matchmaking surface. Rooms and room instances are D1-backed (matchmaking diff --git a/apps/match/src/rooms-db.ts b/apps/match/src/rooms-db.ts deleted file mode 100644 index 3bf1bbb..0000000 --- a/apps/match/src/rooms-db.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Read helpers for the shared `recflare` D1 database. The schema, migrations, - * and seed are owned by the `rooms` worker (apps/rooms/src/rooms-db.ts + - * migrations); this worker binds the same database read-only to resolve a room's - * real scene/subroom when building a matchmake instance. Keep these queries in - * sync with the rooms worker's. - */ - -import { Accessibility, Role } from '@repo/domain' - -/** A stored room — the parsed JSON blob (full client-facing room response). */ -export type Room = Record - -interface RoomRow { - data: string -} - -const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) - -export async function getRoomById(db: D1Database, roomId: number): Promise { - return parseOne( - await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first() - ) -} - -export async function getRoomByName(db: D1Database, name: string): Promise { - return parseOne( - await db - .prepare('SELECT data FROM room WHERE name_lower = ?1') - .bind(name.toLowerCase()) - .first() - ) -} - -/** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */ -const DORM_TEMPLATE_ROOM_ID = 1 - -/** A player's username from the shared accounts table (for naming their dorm), or null. */ -export async function getUsername(db: D1Database, accountId: number): Promise { - const row = await db - .prepare('SELECT data FROM accounts WHERE account_id = ?1') - .bind(accountId) - .first<{ data: string }>() - if (!row) return null - const account = JSON.parse(row.data) as { username?: string } - return typeof account.username === 'string' ? account.username : null -} - -/** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */ -export async function getDormRoom(db: D1Database, accountId: number): Promise { - return parseOne( - await db - .prepare('SELECT data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') - .bind(accountId) - .first() - ) -} - -/** - * The player's personal dorm room, created on first access. Cloned from the - * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so - * matchmaking routes them into their own dorm and they can save it via the - * owner-gated room-save. Idempotent: returns the existing dorm once created. - * - * NOTE: this is the one place the match worker writes to the rooms table (the - * `rooms` worker otherwise owns the schema). - */ -export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise { - const existing = await getDormRoom(db, accountId) - if (existing) return existing - - const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID) - const idRow = await db - .prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room') - .first<{ next: number }>() - const roomId = idRow?.next ?? 2 - - // Reuse the template's subroom (scene/capacity), owned by the player, starting - // from a clean save. Fall back to the base dorm scene if the template is absent. - const templateSub = - template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0 - ? (template.SubRooms[0] as Record) - : { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 } - - // Named after the owner: `@'s Dorm` (falls back to the account id). - const username = (await getUsername(db, accountId)) ?? `Player${accountId}` - - const room: Room = { - ...(template ?? { Accessibility: Accessibility.Unlisted }), - RoomId: roomId, - Name: `@${username}'s Dorm`, - CreatorAccountId: accountId, - IsDorm: true, - Roles: [{ AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }], - SubRooms: [{ ...templateSub, CreatorAccountId: accountId }], - CreatedAt: new Date().toISOString(), - } - await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(room)).run() - return room -} diff --git a/apps/rooms/package.json b/apps/rooms/package.json index 42289ee..c27dfe6 100644 --- a/apps/rooms/package.json +++ b/apps/rooms/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "hono": "4.12.27", diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 99d16cb..6def465 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1,9 +1,6 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { logger, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' - import { cloneRoom, findSubRoom, @@ -30,7 +27,9 @@ import { toggleCheer, toggleFavorite, toggleRoomTag, -} from './rooms-db' +} from '@repo/domain' +import { logger, withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { App } from './context' diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 7b09631..dd770b9 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -3,13 +3,14 @@ import { beforeAll, describe, expect, it } from 'vitest' import '../../rooms.app' +import { ROOM_SCHEMA_DDL } from '@repo/domain' + import importRooms from '../../../static/ImportRooms.json' import { createRoomInstance, getRoomInstance, SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL, } from '../../room-instance-db' -import { SCHEMA_DDL } from '../../rooms-db' import type { Env } from '../../context' @@ -47,7 +48,7 @@ async function bearer(sub: string): Promise> { 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 SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INSTANCE_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)))) diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index e1cf840..17f6a59 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,2 +1,3 @@ export { RoomInstanceType, Accessibility, Role } from './enums' export * from './accounts-db' +export * from './rooms-db' diff --git a/apps/rooms/src/rooms-db.ts b/packages/domain/src/rooms-db.ts similarity index 85% rename from apps/rooms/src/rooms-db.ts rename to packages/domain/src/rooms-db.ts index cbdb8d3..9bcf42c 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -1,18 +1,26 @@ /** - * Room storage on D1. Each room is a single JSON blob in the `data` column; - * queryable fields (RoomId, Name, CreatorAccountId, IsDorm) are SQLite - * generated (virtual) columns extracted from that JSON and indexed. This keeps - * the room shape flexible while still allowing fast lookups by id/name/creator. + * Room storage on the shared `recflare` D1 database. Each room is a single JSON + * blob in the `data` column; queryable fields (RoomId, Name, CreatorAccountId, + * IsDorm) are SQLite generated (virtual) columns extracted from that JSON and + * indexed. This keeps the room shape flexible while still allowing fast lookups + * by id/name/creator — the same JSON-blob pattern `accounts-db` uses. * - * `SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql` - * created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); - * the room data is seeded from `static/ImportRooms.json` by - * `migrations/0002_import_rooms.sql`. Tests apply `SCHEMA_DDL` then seed the + * `ROOM_SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql` + * created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); the + * room data is seeded from `apps/rooms/static/ImportRooms.json` by + * `migrations/0002_import_rooms.sql`. Tests apply `ROOM_SCHEMA_DDL` then seed the * imported rooms directly. + * + * This module is the single source of truth for the helpers: the `rooms` worker + * (which owns the schema/migrations) uses the read/write set; the `match` worker + * uses the room lookups plus the dorm helpers; the `api` worker binds the same + * database read-only and uses `getRoomById`. Each imports the subset it needs. */ +import { Accessibility, Role } from './enums' + /** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */ -export const SCHEMA_DDL: string[] = [ +export const ROOM_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS room ( data TEXT NOT NULL, room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, @@ -47,9 +55,6 @@ interface RoomRole { InvitedRole: number } -/** Owner role value (max byte) — the room creator's tier. */ -const ROLE_OWNER = 255 - /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given @@ -81,7 +86,7 @@ export async function cloneRoom( // any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT // carry over, or the clone would still list the template's owner as owner. const roles: RoomRole[] = [ - { AccountId: accountId, Role: ROLE_OWNER, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }, ] const cloned: Room = { @@ -127,17 +132,17 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st .run() } -/** - * Add a user tag (`Type: 0`) to a room's `Tags`, skipping it when already present - * (case-insensitive). The caller supplies the already-loaded room (owner-checked) - * to avoid a re-read; the whole room JSON is rewritten. Returns the updated room. - */ /** * Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so * setting one clears any other main tag. Compared case-insensitively. */ const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art']) +/** + * Add a user tag (`Type: 0`) to a room's `Tags`, skipping it when already present + * (case-insensitive). The caller supplies the already-loaded room (owner-checked) + * to avoid a re-read; the whole room JSON is rewritten. Returns the updated room. + */ export async function toggleRoomTag( db: D1Database, roomId: number, @@ -668,3 +673,70 @@ export async function getBaseRooms(db: D1Database, skip: number, take: number): .sort((a, b) => roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take) } + +/** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */ +const DORM_TEMPLATE_ROOM_ID = 1 + +/** A player's username from the shared accounts table (for naming their dorm), or null. */ +export async function getUsername(db: D1Database, accountId: number): Promise { + const row = await db + .prepare('SELECT data FROM accounts WHERE account_id = ?1') + .bind(accountId) + .first<{ data: string }>() + if (!row) return null + const account = JSON.parse(row.data) as { username?: string } + return typeof account.username === 'string' ? account.username : null +} + +/** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */ +export async function getDormRoom(db: D1Database, accountId: number): Promise { + return parseOne( + await db + .prepare('SELECT data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') + .bind(accountId) + .first() + ) +} + +/** + * The player's personal dorm room, created on first access. Cloned from the + * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so + * matchmaking routes them into their own dorm and they can save it via the + * owner-gated room-save. Idempotent: returns the existing dorm once created. + * + * NOTE: this is the one place the match worker writes to the rooms table (the + * `rooms` worker otherwise owns the schema). + */ +export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise { + const existing = await getDormRoom(db, accountId) + if (existing) return existing + + const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID) + const idRow = await db + .prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room') + .first<{ next: number }>() + const roomId = idRow?.next ?? 2 + + // Reuse the template's subroom (scene/capacity), owned by the player, starting + // from a clean save. Fall back to the base dorm scene if the template is absent. + const templateSub = + template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0 + ? (template.SubRooms[0] as Record) + : { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 } + + // Named after the owner: `@'s Dorm` (falls back to the account id). + const username = (await getUsername(db, accountId)) ?? `Player${accountId}` + + const room: Room = { + ...(template ?? { Accessibility: Accessibility.Unlisted }), + RoomId: roomId, + Name: `@${username}'s Dorm`, + CreatorAccountId: accountId, + IsDorm: true, + Roles: [{ AccountId: accountId, Role: Role.Owner, LastChangedByAccountId: null, InvitedRole: 0 }], + SubRooms: [{ ...templateSub, CreatorAccountId: accountId }], + CreatedAt: new Date().toISOString(), + } + await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(room)).run() + return room +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2f1fb5..28ea027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: apps/api: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers @@ -492,6 +495,9 @@ importers: apps/rooms: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers