[match] better invites

This commit is contained in:
Devin Zuczek
2026-08-22 12:23:48 -04:00
parent 1a2643240b
commit be24e62f1f
9 changed files with 204 additions and 12 deletions
+10 -5
View File
@@ -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
@@ -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);
+1
View File
@@ -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": {
+24 -6
View File
@@ -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<App>()
// `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<App>()
'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 instances `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<App>()
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)
}
)
+14
View File
@@ -384,3 +384,17 @@ export const InviteRequest = z.object({
.optional()
.describe('The callers room instance to invite them into; resolves the invites 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 didnt resolve'),
})
@@ -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<string, unknown>
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<string, unknown>
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()
+6 -1
View File
@@ -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
+1
View File
@@ -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'
+92
View File
@@ -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<RoomInvite | null> {
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<RoomInviteRow>()
if (!row) return null
return {
RoomInviteId: row.room_invite_id,
FromPlayerId: row.from_player_id,
ToPlayerId: row.to_player_id,
RoomId: row.room_id,
}
}