From be24e62f1ff6a5a7ffc9088069e02b32f617f79b Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 22 Aug 2026 12:23:48 -0400 Subject: [PATCH] [match] better invites --- apps/match/README.md | 15 ++-- apps/match/migrations/0001_room_invite.sql | 30 +++++++ apps/match/package.json | 1 + apps/match/src/match.app.ts | 30 +++++-- apps/match/src/openapi.ts | 14 ++++ apps/match/src/test/integration/api.test.ts | 26 ++++++ apps/match/wrangler.jsonc | 7 +- packages/domain/src/index.ts | 1 + packages/domain/src/room-invites-db.ts | 92 +++++++++++++++++++++ 9 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 apps/match/migrations/0001_room_invite.sql create mode 100644 packages/domain/src/room-invites-db.ts diff --git a/apps/match/README.md b/apps/match/README.md index c624ecd..09425bc 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -123,11 +123,16 @@ substitutes the same as asking by id. | `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | | `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` | -The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker; -this worker has no migrations of its own. The settings KV is owned by the -`playersettings` worker; this worker touches exactly one key in it, the "avoid juniors" -preference, and its write merges (as that worker's own PUT does) so the rest of the -player's settings survive. +The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker. This +worker owns one table of its own, `room_invite` (`migrations/0001_room_invite.sql`, applied +under its own `d1_migrations_match` table so it doesn't clash with the other workers +sharing the database): a row per game invite `POST /invite` sends, which is what gives the +invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds, so old +invites can be swept later. + +The settings KV is owned by the `playersettings` worker; this worker touches exactly one +key in it, the "avoid juniors" preference, and its write merges (as that worker's own PUT +does) so the rest of the player's settings survive. ## Known gaps diff --git a/apps/match/migrations/0001_room_invite.sql b/apps/match/migrations/0001_room_invite.sql new file mode 100644 index 0000000..21fe0e1 --- /dev/null +++ b/apps/match/migrations/0001_room_invite.sql @@ -0,0 +1,30 @@ +-- Room invites — one row per game invite a player sends another ("come join me in this +-- room"), as `POST /invite` creates them. Generated from +-- packages/domain/src/room-invites-db.ts (ROOM_INVITE_SCHEMA_DDL) — keep in sync. +-- +-- The invite that reaches the invitee is a live notification, not a row: the worker pushes +-- a MessageReceived frame the moment the invite is created. This table exists so the invite +-- has an id of its own — `RoomInviteId`, which the create response hands back — and so an +-- invite can be looked up or expired after the fact rather than vanishing with the frame. + +-- `room_invite_id` is AUTOINCREMENT rather than a bare rowid alias: the id is handed to the +-- client, and expiring old invites deletes rows, so a reused id would point a client's stale +-- invite at somebody else's. +-- +-- `room_id` is nullable because the invite is: the caller names a room INSTANCE, and one +-- that has already died (or was never real) leaves the invite with nothing to resolve — the +-- worker sends it anyway, with a null RoomId, so the row records the same thing. +-- +-- `created_at` is epoch SECONDS, like `presence.expires_at` on the same database and for the +-- same reason: the sweep that will expire these compares it against `Date.now()/1000` in +-- SQL, and an integer compare needs no parsing. +CREATE TABLE IF NOT EXISTS room_invite ( + room_invite_id INTEGER PRIMARY KEY AUTOINCREMENT, + from_player_id INTEGER NOT NULL, + to_player_id INTEGER NOT NULL, + room_id INTEGER, + created_at INTEGER NOT NULL + ); + +-- For the expiry sweep: "everything older than X". +CREATE INDEX IF NOT EXISTS idx_room_invite_created ON room_invite (created_at); diff --git a/apps/match/package.json b/apps/match/package.json index 9d81c8b..539487b 100644 --- a/apps/match/package.json +++ b/apps/match/package.json @@ -12,6 +12,7 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 3d89d9d..7cd4562 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -7,6 +7,7 @@ import { areFriends, canManageRoom, createRoomInstance, + createRoomInvite, deleteEmptyRoomInstances, deleteExpiredPresence, deletePresence, @@ -61,6 +62,7 @@ import { form, InProgressRequest, InviteRequest, + InviteResponse, JoinModeRequest, json, jsonBody, @@ -610,6 +612,7 @@ function nextLiveMessageId(): number { return Date.now() } + /** * Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived` * frame carrying a game-invite `Message` the client renders the join prompt from. `data` @@ -2262,9 +2265,11 @@ const app = new Hono() // `roomInstanceId` they're being invited into. Delivers a game-invite Message to the // target over the notify hub as a MessageReceived frame — the client renders the // join prompt from it. When the room instance resolves, its RoomId rides along on the - // message so the client knows which room the invite points at. Always acks 200 (a bad - // playerId is a 400, a missing token a 401); hub delivery is best-effort, so a target - // who's offline simply has the frame queued (or dropped) without failing the invite. + // message so the client knows which room the invite points at. The invite is also + // recorded as a `room_invite` row, and that row IS the response — + // `{ RoomInviteId, FromPlayerId, ToPlayerId, RoomId }` (a bad playerId is a 400, a + // missing token a 401). Hub delivery is best-effort, so a target who's offline simply + // has the frame queued (or dropped) without failing the invite. .post( '/invite', describeRoute({ @@ -2274,14 +2279,16 @@ const app = new Hono() 'Sends a game invite from the caller (the Bearer token) to `playerId` for', '`roomInstanceId`. Delivered to the target over the notify hub as a `MessageReceived`', 'notification carrying a game-invite `Message`; the resolved instance’s `RoomId` rides', - 'on the message. Acks 200 (bad `playerId` → 400); hub delivery is best-effort.', + 'on the message. The invite is recorded as a `room_invite` row and that row is the', + 'response (bad `playerId` → 400); hub delivery is best-effort.', ].join(' '), security: AUTHED, requestBody: form(InviteRequest, 'The target player and the room instance'), responses: { - 200: EMPTY_OK, + 200: json(InviteResponse, 'The invite that was sent'), 400: { description: 'Missing, non-numeric, or zero playerId (empty body)' }, 401: UNAUTHORIZED_RESPONSE, + 500: { description: 'The invite could not be recorded; nothing was sent (empty body)' }, }, }), async (c) => { @@ -2306,8 +2313,19 @@ const app = new Hono() if (instance) roomId = instance.roomId } + // Record the invite before delivering it: the row is what mints the `RoomInviteId` + // the response carries, while the frame itself is fire-and-forget. + const invite = await createRoomInvite(c.env.DB, id, toPlayerId, roomId) + // No row, no id to answer with — and nothing for a later lookup or expiry to find. + // A server fault rather than the caller's, so don't push the frame either: an + // invite the response can't name is worse than no invite. + if (invite === null) { + logger.error('failed to record room invite', { fromPlayerId: id, toPlayerId, roomId }) + return c.body(null, 500) + } + await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId) - return c.body(null, 200) + return c.json(invite) } ) diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 328fdf1..c9d65a7 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -384,3 +384,17 @@ export const InviteRequest = z.object({ .optional() .describe('The caller’s room instance to invite them into; resolves the invite’s RoomId'), }) + +/** + * `POST /invite` response — the `room_invite` row the invite just created. The frame the + * invitee receives is ephemeral; the row is what gives the invite an id of its own. + */ +export const InviteResponse = z.object({ + RoomInviteId: z.int().describe('Id of the new `room_invite` row'), + FromPlayerId: z.int().describe('The caller (the Bearer token)'), + ToPlayerId: z.int().describe('The invited account'), + RoomId: z + .int() + .nullable() + .describe('The room the invite points at; null when the room instance didn’t resolve'), +}) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 9b3ed9f..c7cf9b7 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -16,6 +16,7 @@ import { getRoomInstance, PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL, + ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL, @@ -120,6 +121,8 @@ beforeAll(async () => { for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat. for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Room invites (owned by this worker) — POST /invite mints a row per invite. + for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Accounts table (owned by the auth worker) — dorm creation reads the username // to name the room. Seed the players the dorm tests authenticate as. @@ -2242,6 +2245,25 @@ describe('auth-gated endpoints', () => { // The client's exact request: player 42 invites 153 into their instance. const res = await invite(`playerId=153&roomInstanceId=${instance.roomInstanceId}`, '42') expect(res.status).toBe(200) + // The response is the `room_invite` row the invite just created. + const created = (await res.json()) as Record + expect(created).toMatchObject({ FromPlayerId: 42, ToPlayerId: 153, RoomId: 2 }) + expect(created.RoomInviteId).toBeGreaterThan(0) + + // ...and the row is really there, `created_at` stamped in epoch seconds so the + // eventual expiry sweep can compare it. + const row = await env.DB.prepare( + 'SELECT from_player_id, to_player_id, room_id, created_at FROM room_invite WHERE room_invite_id = ?1' + ) + .bind(created.RoomInviteId) + .first<{ + from_player_id: number + to_player_id: number + room_id: number | null + created_at: number + }>() + expect(row).toMatchObject({ from_player_id: 42, to_player_id: 153, room_id: 2 }) + expect(row!.created_at).toBeGreaterThan(1_700_000_000) const notes = await sent() expect(notes).toHaveLength(1) @@ -2269,6 +2291,10 @@ describe('auth-gated endpoints', () => { // RoomId (which the real hub drops from the frame). const noRoom = await invite('playerId=153&roomInstanceId=999999', '42') expect(noRoom.status).toBe(200) + const noRoomInvite = (await noRoom.json()) as Record + expect(noRoomInvite).toMatchObject({ RoomId: null }) + // Each invite gets its own id. + expect(noRoomInvite.RoomInviteId).not.toBe(created.RoomInviteId) const after = await sent() expect(after).toHaveLength(1) expect(after[0].data.RoomId).toBeNull() diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index a833a51..77522aa 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -7,12 +7,17 @@ // Shared `recflare` DB. Reads room scenes for matchmaking, writes a player's // personal dorm room on first dorm entry (see getOrCreateDormRoom), and holds // player presence (the `presence` table — owned/migrated by the `rooms` worker). + // This worker owns the `room_invite` table behind POST /invite (schema/migration + // here); its own migrations_table keeps that history separate from the other + // workers' migrations on the shared database. // The "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time. "d1_databases": [ { "binding": "DB", "database_name": "recflare", - "database_id": "local" + "database_id": "local", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_match" } ], // Per-player settings KV, owned by the `playersettings` worker. Read-only here, for diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ee56420..04cc20f 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -6,6 +6,7 @@ export * from './password' export * from './rooms-db' export * from './room-instance-db' export * from './room-comments-db' +export * from './room-invites-db' export * from './presence-db' export * from './gifts-db' export * from './inventory-invention-db' diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts new file mode 100644 index 0000000..ce1eafd --- /dev/null +++ b/packages/domain/src/room-invites-db.ts @@ -0,0 +1,92 @@ +/** + * Room invites on the shared `recflare` D1 database — one row per game invite a player + * sends another ("come join me in this room"), as `POST /invite` on the `match` worker + * creates them. + * + * The invite that reaches the invitee is a live notification, not a row: `match` pushes a + * `MessageReceived` frame the moment the invite is created, and the client renders the join + * prompt straight off it. This table exists so the invite has an ID OF ITS OWN — + * `RoomInviteId`, which the create response hands back — and so an invite can be looked up + * or expired after the fact rather than vanishing with the socket frame. + * + * The `match` worker owns this schema/migration + * (`apps/match/migrations/0001_room_invite.sql`, applied under its own `migrations_table` + * so it doesn't clash with the other workers' migrations that share the database). + * `ROOM_INVITE_SCHEMA_DDL` mirrors that migration so tests can build the table directly. + */ + +/** Schema DDL (mirror of apps/match/migrations/0001_room_invite.sql). */ +export const ROOM_INVITE_SCHEMA_DDL: string[] = [ + // `room_invite_id` is AUTOINCREMENT rather than a bare rowid alias: the id is handed to + // the client, and expiring old invites deletes rows, so a reused id would point a + // client's stale invite at somebody else's. + // + // `room_id` is nullable because the invite is: the caller names a room INSTANCE, and one + // that has already died (or was never real) leaves the invite with nothing to resolve — + // `match` sends it anyway, with a null RoomId, so the row records the same thing. + // + // `created_at` is epoch SECONDS, like `presence.expires_at` on the same database and for + // the same reason: the sweep that will expire these compares it against `Date.now()/1000` + // in SQL, and an integer compare needs no parsing. Indexed for that sweep. + `CREATE TABLE IF NOT EXISTS room_invite ( + room_invite_id INTEGER PRIMARY KEY AUTOINCREMENT, + from_player_id INTEGER NOT NULL, + to_player_id INTEGER NOT NULL, + room_id INTEGER, + created_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_room_invite_created ON room_invite (created_at)`, +] + +/** + * One invite as the client reads it — PascalCase, and the whole of what `POST /invite` + * answers with. + */ +export interface RoomInvite { + RoomInviteId: number + FromPlayerId: number + ToPlayerId: number + RoomId: number | null +} + +interface RoomInviteRow { + room_invite_id: number + from_player_id: number + to_player_id: number + room_id: number | null +} + +const SELECT_COLUMNS = `room_invite_id, from_player_id, to_player_id, room_id` + +const nowSeconds = () => Math.floor(Date.now() / 1000) + +/** + * Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the + * client reads it back. `roomId` is null when the caller's room instance didn't resolve. + * + * The caller still has to deliver the invite (the `MessageReceived` frame); this only + * mints the row and its id. + */ +export async function createRoomInvite( + db: D1Database, + fromPlayerId: number, + toPlayerId: number, + roomId: number | null +): Promise { + const row = await db + .prepare( + `INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at) + VALUES (?1, ?2, ?3, ?4) + RETURNING ${SELECT_COLUMNS}` + ) + .bind(fromPlayerId, toPlayerId, roomId, nowSeconds()) + .first() + + if (!row) return null + return { + RoomInviteId: row.room_invite_id, + FromPlayerId: row.from_player_id, + ToPlayerId: row.to_player_id, + RoomId: row.room_id, + } +}