move presence to d1

This commit is contained in:
Devin Zuczek
2026-07-11 12:37:22 -04:00
parent aad184181b
commit fb553c2fd0
16 changed files with 452 additions and 131 deletions
+19
View File
@@ -0,0 +1,19 @@
-- Player presence — the room instance a player is currently in, plus the status
-- fields the match heartbeat echoes back. Stored as a JSON blob in `data` with
-- generated (virtual) columns for the fields we query on, the same pattern as the
-- rooms/room_instance tables. One row per account (unique `account_id`); writes
-- upsert via INSERT OR REPLACE. Rows carry an absolute `expires_at` (epoch
-- seconds) — reads filter expired rows out and a cleanup pass purges them.
-- Generated from packages/domain/src/presence-db.ts (PRESENCE_SCHEMA_DDL) — keep
-- in sync. Written by the match/auth workers, read by match/rooms.
CREATE TABLE IF NOT EXISTS presence (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
room_instance_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstance.roomInstanceId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstance.roomId')) VIRTUAL,
expires_at INTEGER GENERATED ALWAYS AS (json_extract(data, '$.expiresAt')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_presence_account ON presence (account_id);
CREATE INDEX IF NOT EXISTS idx_presence_room_instance ON presence (room_instance_id);
CREATE INDEX IF NOT EXISTS idx_presence_expires ON presence (expires_at);
+3 -5
View File
@@ -9,12 +9,10 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
// Shared `recflare` D1. Owns the rooms tables (JSON blob + generated columns,
// see rooms-db.ts) and migrates the shared `presence` table, which is read here
// to resolve the caller's current room instance for the photon access token.
DB: D1Database
// Shared player-presence KV (owned by the `match` worker). Read here to resolve
// the caller's current room instance for the photon access token — the
// equivalent of the reference server's HeartbeatDB.GetPlayerHeartbeat.
RECFLARE_MATCH_PRESENCE: KVNamespace
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RoomUpdate notifications when a room is mutated.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+5 -11
View File
@@ -10,6 +10,7 @@ import {
getFeaturedRooms,
getHotRooms,
getInteraction,
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomById,
@@ -66,13 +67,9 @@ function allIds(idParam: string): number[] {
* hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
/** Presence KV key (owned by the `match` worker); keep in sync with match's `presenceKey`. */
const presenceKey = (id: number) => `presence:${id}`
/** The slice of the match worker's presence record we read — the caller's current
* room instance. Mirrors the reference server's HeartbeatDB row. */
/** The slice of the shared presence row we read — the caller's current room instance. */
interface PresenceView {
roomInstance?: { roomInstanceId?: number } | null
roomInstanceId?: number
}
/**
@@ -117,15 +114,12 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
/**
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
* resolves the caller, reads their current room instance from the shared
* presence KV, and returns the permissions + token.
* `presence` table (see @repo/domain), and returns the permissions + token.
*/
async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const presence = await c.env.RECFLARE_MATCH_PRESENCE.get<PresenceView>(
presenceKey(accountId),
'json'
)
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
return c.json(photonAccessToken(accountId, roomInstanceId))
}
+12 -4
View File
@@ -6,6 +6,7 @@ import '../../rooms.app'
import {
createRoomInstance,
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
} from '@repo/domain'
@@ -50,6 +51,8 @@ beforeAll(async () => {
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 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))))
})
@@ -785,10 +788,15 @@ describe('rooms endpoints', () => {
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => {
// Seed the caller's presence so RoomInstanceId reflects their current instance.
await env.RECFLARE_MATCH_PRESENCE.put(
'presence:777',
JSON.stringify({ roomInstance: { roomInstanceId: 1000042 } })
)
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 777,
roomInstance: { roomInstanceId: 1000042 },
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const headers = await bearer('777')
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
-8
View File
@@ -19,14 +19,6 @@
"migrations_dir": "migrations"
}
],
// Shared player-presence KV (owned by the `match` worker). Read-only here to
// resolve the caller's current room instance for the photon access token.
"kv_namespaces": [
{
"binding": "RECFLARE_MATCH_PRESENCE",
"id": "local"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
// the `notify` worker). We only invoke its RPC methods; no migration here.
"durable_objects": {