@pithy-sh/matchmaking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, DurableObjectNamespace, KVNamespace } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
7
+ import { InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import type { Context, Hono } from "hono";
10
+ import { createRoom, joinRoom, normalizeCode } from "../code/room";
11
+ import { type MatchmakingConfig, type MatchmakingGame, resolveGame } from "../config/config";
12
+ import type { Invite } from "../data/invite";
13
+ import { matchmakingDatabase } from "../data/tables";
14
+ import { MatchmakingInviteForbiddenError, MatchmakingInviteNotFoundError } from "../error/errors";
15
+ import { friendStore } from "../friends/store";
16
+ import { resolveInvitee } from "../invite/resolve";
17
+ import { type InviteStore, inviteStore } from "../invite/store";
18
+ import type { MatchmakingPresence } from "../presence/durableObject";
19
+ import { PRESENCE_USER_HEADER, type PresenceEvent } from "../presence/protocol";
20
+ import type { MatchmakingQueue } from "../queue/durableObject";
21
+ import { readSkill } from "../queue/skill";
22
+ import { callRpc } from "../rpc";
23
+ import { type SessionNamespace, sessionMinter } from "../session/minter";
24
+ import { requireAuth } from "./guard";
25
+ import { FriendParams, GameParams, InviteBody, InviteParams, RoomCodeParams } from "./schemas";
26
+
27
+ /**
28
+ * The matchmaking HTTP surface. Every route is `requireAuth()`-gated (no public surface) and binds to
29
+ * `c.var.auth.userId`. Verification strategy: `bearer | session` throughout; the room-code join route may
30
+ * additionally stack a Turnstile humanity check when the adopter opts in (abuse flag).
31
+ *
32
+ * Every route also declares what it accepts on its route line, with `zValidator(target, Schema,
33
+ * validationHook)` from `./schemas` — always after the guards, so an unauthenticated caller still gets a
34
+ * 401 rather than a 400. A route that reads no request body declares no `json` validator.
35
+ *
36
+ * Rooms · POST /games/:game/rooms (param GameParams) · POST /rooms/:code/join (param RoomCodeParams)
37
+ * Invites · POST /games/:game/invites (param GameParams, json InviteBody) · GET /invites ·
38
+ * POST /invites/:id/accept (param InviteParams) · POST /invites/:id/decline (param InviteParams)
39
+ * Friends · GET /friends · POST /friends/:userId/request · POST /friends/:userId/accept ·
40
+ * POST /friends/:userId/decline · DELETE /friends/:userId — each param FriendParams
41
+ * Queue · POST /games/:game/queue · GET /games/:game/queue · DELETE /games/:game/queue — each param GameParams
42
+ * Presence · GET /presence (WebSocket; the raw request is forwarded to the Durable Object, body untouched)
43
+ */
44
+ export interface MatchmakingRoutesOptions {
45
+ config: MatchmakingConfig;
46
+ basePath?: string;
47
+ }
48
+
49
+ export function registerMatchmakingRoutes(options: MatchmakingRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
50
+ const base = options.basePath ?? "/matchmaking";
51
+ const { config } = options;
52
+
53
+ return (app) => {
54
+ // --- Rooms ---
55
+ app.post(`${base}/games/:game/rooms`, requireAuth(), zValidator("param", GameParams, validationHook), async (c) => {
56
+ const game = resolveMatchmakingGame(config, c.req.valid("param").game);
57
+ const result = await createRoom(kvOf(c), game, minterOf(c), userId(c), new Date());
58
+ return c.json(result, 201);
59
+ });
60
+
61
+ app.post(
62
+ `${base}/rooms/:code/join`,
63
+ requireAuth(),
64
+ zValidator("param", RoomCodeParams, validationHook),
65
+ async (c) => {
66
+ // The param schema is a length bound; `normalizeCode` still owns the code's real shape.
67
+ const code = normalizeCode(c.req.valid("param").code);
68
+ const result = await joinRoom(kvOf(c), minterOf(c), code, userId(c), new Date());
69
+ return c.json(result, 200);
70
+ },
71
+ );
72
+
73
+ // --- Invites ---
74
+ app.post(
75
+ `${base}/games/:game/invites`,
76
+ requireAuth(),
77
+ zValidator("param", GameParams, validationHook),
78
+ zValidator("json", InviteBody, validationHook),
79
+ async (c) => {
80
+ const game = resolveMatchmakingGame(config, c.req.valid("param").game);
81
+ // A direct invite seats exactly two players (inviter + invitee). A game that needs a larger roster
82
+ // cannot be filled this way — use a room code or the open queue — so reject it with a clear error
83
+ // rather than mint a session that can never start. Hand-thrown, not Zod: the roster is config, not
84
+ // request shape, so no body schema can express it.
85
+ if (game.players !== 2) {
86
+ throw new ValidationError({
87
+ message: "Direct invites are for two-player games.",
88
+ action: "Use a room code or the open queue for games with more than two players.",
89
+ detail: `Game "${game.key}" needs ${game.players} players; a direct invite seats only two.`,
90
+ });
91
+ }
92
+ const inviteeId = await resolveInvitee(dbBinding(c), c.req.valid("json"));
93
+ const invite = await inviteStore(matchmakingDatabase(dbBinding(c))).create({
94
+ id: crypto.randomUUID(),
95
+ gameKey: game.key,
96
+ inviterId: userId(c),
97
+ inviteeId,
98
+ at: new Date(),
99
+ });
100
+ await notify(c, inviteeId, { type: "invite", inviteId: invite.id, gameKey: game.key, from: userId(c) });
101
+ return c.json(invite, 201);
102
+ },
103
+ );
104
+
105
+ app.get(`${base}/invites`, requireAuth(), async (c) => {
106
+ const invites = await inviteStore(matchmakingDatabase(dbBinding(c))).pendingFor(userId(c));
107
+ return c.json({ invites });
108
+ });
109
+
110
+ app.post(
111
+ `${base}/invites/:id/accept`,
112
+ requireAuth(),
113
+ zValidator("param", InviteParams, validationHook),
114
+ async (c) => {
115
+ const store = inviteStore(matchmakingDatabase(dbBinding(c)));
116
+ const invite = await requireInvitee(c, store, c.req.valid("param").id);
117
+ const game = resolveMatchmakingGame(config, invite.gameKey);
118
+ const sessionId = await minterOf(c).mint(game.snapshot, game.players, [invite.inviterId, invite.inviteeId]);
119
+ const accepted = await store.accept(invite.id, sessionId, new Date());
120
+ await notify(c, invite.inviterId, { type: "match_found", sessionId, gameKey: game.key });
121
+ return c.json(accepted, 200);
122
+ },
123
+ );
124
+
125
+ app.post(
126
+ `${base}/invites/:id/decline`,
127
+ requireAuth(),
128
+ zValidator("param", InviteParams, validationHook),
129
+ async (c) => {
130
+ const store = inviteStore(matchmakingDatabase(dbBinding(c)));
131
+ const invite = await requireInvitee(c, store, c.req.valid("param").id);
132
+ return c.json(await store.decline(invite.id, new Date()), 200);
133
+ },
134
+ );
135
+
136
+ // --- Friends ---
137
+ if (config.friends) {
138
+ app.get(`${base}/friends`, requireAuth(), async (c) => {
139
+ const friends = await friendStore(matchmakingDatabase(dbBinding(c))).list(userId(c));
140
+ return c.json({ friends });
141
+ });
142
+
143
+ app.post(
144
+ `${base}/friends/:userId/request`,
145
+ requireAuth(),
146
+ zValidator("param", FriendParams, validationHook),
147
+ async (c) => {
148
+ const other = c.req.valid("param").userId;
149
+ await friendStore(matchmakingDatabase(dbBinding(c))).request(userId(c), other, new Date());
150
+ await notify(c, other, { type: "friend_request", from: userId(c) });
151
+ return c.body(null, 204);
152
+ },
153
+ );
154
+
155
+ app.post(
156
+ `${base}/friends/:userId/accept`,
157
+ requireAuth(),
158
+ zValidator("param", FriendParams, validationHook),
159
+ async (c) => {
160
+ await friendStore(matchmakingDatabase(dbBinding(c))).accept(
161
+ userId(c),
162
+ c.req.valid("param").userId,
163
+ new Date(),
164
+ );
165
+ return c.body(null, 204);
166
+ },
167
+ );
168
+
169
+ app.post(
170
+ `${base}/friends/:userId/decline`,
171
+ requireAuth(),
172
+ zValidator("param", FriendParams, validationHook),
173
+ async (c) => {
174
+ await friendStore(matchmakingDatabase(dbBinding(c))).decline(userId(c), c.req.valid("param").userId);
175
+ return c.body(null, 204);
176
+ },
177
+ );
178
+
179
+ app.delete(
180
+ `${base}/friends/:userId`,
181
+ requireAuth(),
182
+ zValidator("param", FriendParams, validationHook),
183
+ async (c) => {
184
+ await friendStore(matchmakingDatabase(dbBinding(c))).remove(userId(c), c.req.valid("param").userId);
185
+ return c.body(null, 204);
186
+ },
187
+ );
188
+ }
189
+
190
+ // --- Open queue ---
191
+ app.post(`${base}/games/:game/queue`, requireAuth(), zValidator("param", GameParams, validationHook), async (c) => {
192
+ const game = resolveMatchmakingGame(config, c.req.valid("param").game);
193
+ const skill = game.skillPool ? await readSkill(dbBinding(c), game.skillPool, userId(c)) : null;
194
+ const status = await callRpc(() =>
195
+ queueStub(c, game.key).enqueue({
196
+ userId: userId(c),
197
+ skill,
198
+ region: region(c),
199
+ gameKey: game.key,
200
+ players: game.players,
201
+ settings: game.queue,
202
+ snapshot: game.snapshot,
203
+ }),
204
+ );
205
+ return c.json(status, 200);
206
+ });
207
+
208
+ app.get(`${base}/games/:game/queue`, requireAuth(), zValidator("param", GameParams, validationHook), async (c) => {
209
+ const game = resolveMatchmakingGame(config, c.req.valid("param").game);
210
+ return c.json(await callRpc(() => queueStub(c, game.key).status(userId(c))), 200);
211
+ });
212
+
213
+ app.delete(
214
+ `${base}/games/:game/queue`,
215
+ requireAuth(),
216
+ zValidator("param", GameParams, validationHook),
217
+ async (c) => {
218
+ const game = resolveMatchmakingGame(config, c.req.valid("param").game);
219
+ await callRpc(() => queueStub(c, game.key).leave(userId(c)));
220
+ return c.body(null, 204);
221
+ },
222
+ );
223
+
224
+ // --- Presence (WebSocket) ---
225
+ app.get(`${base}/presence`, requireAuth(), async (c) => {
226
+ const request = new Request(c.req.url, c.req.raw);
227
+ request.headers.set(PRESENCE_USER_HEADER, userId(c));
228
+ return presenceStub(c).fetch(request);
229
+ });
230
+ };
231
+ }
232
+
233
+ // --- helpers ---
234
+
235
+ function resolveMatchmakingGame(config: MatchmakingConfig, key: string): MatchmakingGame {
236
+ const game = resolveGame(config, key);
237
+ if (!game) {
238
+ throw new NotFoundError({
239
+ message: "That game does not exist.",
240
+ action: "Check the game key against the `games` list in pithy.config.ts.",
241
+ detail: `No matchmaking game "${key}" is configured.`,
242
+ });
243
+ }
244
+ return game;
245
+ }
246
+
247
+ async function requireInvitee(c: Context<PithyHonoEnv>, store: InviteStore, id: string): Promise<Invite> {
248
+ const invite = await store.get(id);
249
+ if (!invite) throw new MatchmakingInviteNotFoundError({ detail: `No invite "${id}".` });
250
+ if (invite.inviteeId !== userId(c)) {
251
+ throw new MatchmakingInviteForbiddenError({ detail: "Only the invitee may respond." });
252
+ }
253
+ return invite;
254
+ }
255
+
256
+ async function notify(c: Context<PithyHonoEnv>, target: string, event: PresenceEvent): Promise<void> {
257
+ try {
258
+ await presenceStub(c).notify(target, event);
259
+ } catch {
260
+ // Best-effort: a notification never fails the action that triggered it.
261
+ }
262
+ }
263
+
264
+ function userId(c: Context<PithyHonoEnv>): string {
265
+ const auth = c.var.auth;
266
+ if (!auth) throw new InternalError({ detail: "requireAuth() must run before a handler reads the user id." });
267
+ return auth.userId;
268
+ }
269
+
270
+ function dbBinding(c: Context<PithyHonoEnv>): D1Database {
271
+ const db = c.env.DB as D1Database | undefined;
272
+ if (!db) throw new InternalError({ detail: "The matchmaking routes require a `DB` D1 binding." });
273
+ return db;
274
+ }
275
+
276
+ function kvOf(c: Context<PithyHonoEnv>): KVNamespace {
277
+ const kv = c.env.MATCHMAKING as KVNamespace | undefined;
278
+ if (!kv) throw new InternalError({ detail: "The matchmaking routes require a `MATCHMAKING` KV binding." });
279
+ return kv;
280
+ }
281
+
282
+ function minterOf(c: Context<PithyHonoEnv>) {
283
+ return sessionMinter(c.env.SESSIONS as SessionNamespace | undefined);
284
+ }
285
+
286
+ function queueStub(c: Context<PithyHonoEnv>, gameKey: string) {
287
+ const ns = c.env.QUEUE as DurableObjectNamespace<MatchmakingQueue> | undefined;
288
+ if (!ns) throw new InternalError({ detail: "The matchmaking routes require a `QUEUE` Durable Object binding." });
289
+ return ns.get(ns.idFromName(gameKey));
290
+ }
291
+
292
+ function presenceStub(c: Context<PithyHonoEnv>) {
293
+ const ns = c.env.PRESENCE as DurableObjectNamespace<MatchmakingPresence> | undefined;
294
+ if (!ns) throw new InternalError({ detail: "The matchmaking routes require a `PRESENCE` Durable Object binding." });
295
+ return ns.get(ns.idFromName("presence"));
296
+ }
297
+
298
+ function region(c: Context<PithyHonoEnv>): string {
299
+ const cf = (c.req.raw as { cf?: { country?: string; colo?: string } }).cf;
300
+ return cf?.country ?? cf?.colo ?? "unknown";
301
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * What the matchmaking routes accept. Every schema here is named on a route line via
8
+ * `zValidator(target, Schema, validationHook)`, so reading `routes.ts` tells you the shape of a request
9
+ * without opening a handler.
10
+ *
11
+ * Path params are **shape** checks, not existence checks. A game key, a room code, an invite id and a
12
+ * user id are all bounded here so an unbounded string never reaches a store or a KV key; whether the
13
+ * value *resolves* stays with the code that owns that answer — `resolveMatchmakingGame` still raises its
14
+ * 404 for an unconfigured game, `normalizeCode` still raises `matchmaking/invalid_code` for a code that
15
+ * is the wrong shape, and the invite store still raises `matchmaking/invite_not_found`. Building a param
16
+ * schema out of the configured game keys would move a 404 to a 400 and leak the config to the caller.
17
+ */
18
+
19
+ /** A game key on the path. Shape only — `resolveMatchmakingGame` owns the 404 for an unconfigured key. */
20
+ export const GameParams = z
21
+ .object({
22
+ game: z
23
+ .string()
24
+ .min(1)
25
+ .max(64)
26
+ .describe("The matchmaking game key from the path, resolved against the configured `games` list."),
27
+ })
28
+ .describe("The path params of a per-game route: which game the caller is addressing.");
29
+ export type GameParams = z.infer<typeof GameParams>;
30
+
31
+ /**
32
+ * A room code on the path. Deliberately tolerant: `normalizeCode` uppercases, strips whitespace, and
33
+ * accepts the dashless `WXYZ1234` as well as the canonical `WXYZ-1234`, so this is a length sanity bound
34
+ * only. `normalizeCode` still runs and still throws `matchmaking/invalid_code` for a genuinely bad code.
35
+ */
36
+ export const RoomCodeParams = z
37
+ .object({
38
+ code: z.string().min(1).max(32).describe("The shareable room code from the path, before normalization."),
39
+ })
40
+ .describe("The path params of the room-code join route.");
41
+ export type RoomCodeParams = z.infer<typeof RoomCodeParams>;
42
+
43
+ /** An invite id on the path. Invite ids are minted with `crypto.randomUUID()`, so the shape is a UUID. */
44
+ export const InviteParams = z
45
+ .object({
46
+ id: z.uuid().describe("The invite's UUID from the path — the externally-referenced invite id."),
47
+ })
48
+ .describe("The path params of an invite accept/decline route.");
49
+ export type InviteParams = z.infer<typeof InviteParams>;
50
+
51
+ /** The other user on a friend route. Auth user ids are opaque text, so this is a length bound only. */
52
+ export const FriendParams = z
53
+ .object({
54
+ userId: z.string().min(1).max(255).describe("The other user in the friendship, as an auth user id."),
55
+ })
56
+ .describe("The path params of a friend-graph route: whom the friendship is with.");
57
+ export type FriendParams = z.infer<typeof FriendParams>;
58
+
59
+ /**
60
+ * Whom to invite. `resolveInvitee` re-checks the same exclusive-or for direct, non-HTTP callers — this
61
+ * schema is the HTTP boundary's copy of that rule, and answers a 400 instead of the resolver's 404.
62
+ */
63
+ export const InviteBody = z
64
+ .object({
65
+ email: z.string().email().optional().describe("The invitee's email — the reliable, unique identity key."),
66
+ name: z.string().min(1).optional().describe("The invitee's screen name — best-effort, may be ambiguous."),
67
+ })
68
+ .refine((b) => (b.email ? 1 : 0) + (b.name ? 1 : 0) === 1, {
69
+ message: "Provide exactly one of `email` or `name`.",
70
+ })
71
+ .describe("Whom to invite: an email or a screen name.");
72
+ export type InviteBody = z.infer<typeof InviteBody>;
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add matchmaking` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory and its config/options types. Every other module is imported by deep
7
+ * path; this is the documented contract, not a barrel.
8
+ *
9
+ * **The two Durable Objects are deliberately not here.** `MatchmakingQueue` and `MatchmakingPresence`
10
+ * import `cloudflare:workers`, which resolves in workerd and nowhere else, and this module is what an
11
+ * adopter's `pithy.config.ts` imports — a file every Node-side CLI command loads. Re-exporting the classes
12
+ * from here put both Durable Object chains on that path, so `pithy upgrade`, `pithy migrate`, and
13
+ * `pithy deploy` would have died with "Could not load pithy.config.ts" for any project composing
14
+ * matchmaking (#180, the defect `@pithy-sh/multiplayer` shipped as #172). The factory and the Durable
15
+ * Objects are two things with two runtimes, and this entry point carries only the first.
16
+ *
17
+ * The adopter's worker still exports the classes, from their own modules:
18
+ *
19
+ * ```ts
20
+ * export { MatchmakingQueue } from "@pithy-sh/matchmaking/src/queue/durableObject";
21
+ * export { MatchmakingPresence } from "@pithy-sh/matchmaking/src/presence/durableObject";
22
+ * ```
23
+ *
24
+ * — which is what wrangler's `class_name` resolves against, for the `QUEUE` and `PRESENCE` bindings
25
+ * `matchmaking()` declares. The CLI writes the bindings and the class migration tags for you.
26
+ */
27
+
28
+ export {
29
+ isMatchmakingCapability,
30
+ MATCHMAKING_MIGRATION_ORDER,
31
+ type MatchmakingCapability,
32
+ type MatchmakingOptions,
33
+ matchmaking,
34
+ } from "./capability";
35
+ export {
36
+ MatchmakingConfig,
37
+ type MatchmakingConfigInput,
38
+ MatchmakingGame,
39
+ MatchmakingQueueSettings,
40
+ MatchmakingRoomCodes,
41
+ MatchmakingSnapshot,
42
+ resolveGame,
43
+ } from "./config/config";
44
+ export { FriendStatus, Friendship } from "./data/friend";
45
+ export { Invite, InviteStatus } from "./data/invite";
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
6
+ import { MatchmakingUserNotFoundError } from "../error/errors";
7
+
8
+ /**
9
+ * Resolve an invite target — an email or a screen name — to a single authenticated user id, via the
10
+ * optional `@pithy-sh/auth` seam (dynamic-imported, like multiplayer → leaderboard). Email is the reliable
11
+ * key (unique on the user table); a display name is best-effort and may be ambiguous. Auth exposes no
12
+ * unique screen name, so a name that matches zero or many users throws `matchmaking/user_not_found` —
13
+ * invite by email for certainty. If `@pithy-sh/auth` is not installed, resolution is impossible.
14
+ */
15
+
16
+ /** How an invitee is addressed — exactly one of these. */
17
+ export interface InviteTarget {
18
+ email?: string;
19
+ name?: string;
20
+ }
21
+
22
+ export async function resolveInvitee(db: D1Database, target: InviteTarget): Promise<string> {
23
+ const byEmail = typeof target.email === "string" && target.email.length > 0;
24
+ const byName = typeof target.name === "string" && target.name.length > 0;
25
+ if (byEmail === byName) {
26
+ throw new MatchmakingUserNotFoundError({
27
+ detail: "Provide exactly one of email or name to resolve an invitee.",
28
+ });
29
+ }
30
+
31
+ let authDatabase: typeof import("@pithy-sh/auth/src/data/tables").authDatabase;
32
+ let User: typeof import("@pithy-sh/auth/src/data/betterAuth").User;
33
+ try {
34
+ ({ authDatabase } = await import("@pithy-sh/auth/src/data/tables"));
35
+ ({ User } = await import("@pithy-sh/auth/src/data/betterAuth"));
36
+ } catch (cause) {
37
+ throw new MatchmakingUserNotFoundError(
38
+ { detail: "@pithy-sh/auth is required to resolve an invitee by email or name." },
39
+ { cause },
40
+ );
41
+ }
42
+
43
+ const auth = authDatabase(db);
44
+
45
+ if (byEmail) {
46
+ const row = await auth
47
+ .selectFrom("pithyAuthUsers")
48
+ // Normalized, so an invite typed `Ada@Example.com` finds the account that signed in as
49
+ // `ada@example.com`. Both sides of every address comparison in the kit go through this rule.
50
+ .where("email", "=", normalizeAddress(target.email as string))
51
+ .selectAll()
52
+ .executeTakeFirst();
53
+ if (!row) {
54
+ throw new MatchmakingUserNotFoundError({ detail: `No user with email ${target.email}.` });
55
+ }
56
+ return User.parse(row).id;
57
+ }
58
+
59
+ // Names are non-unique: zero or many matches is unresolvable.
60
+ const rows = await auth
61
+ .selectFrom("pithyAuthUsers")
62
+ .where("name", "=", target.name as string)
63
+ .selectAll()
64
+ .execute();
65
+ if (rows.length !== 1) {
66
+ throw new MatchmakingUserNotFoundError({
67
+ detail: `Name ${target.name} matched ${rows.length} users; invite by email for a unique identity.`,
68
+ });
69
+ }
70
+ return User.parse(rows[0]).id;
71
+ }
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { Invite } from "../data/invite";
5
+ import { MATCHMAKING_INVITES_TABLE, type MatchmakingDatabase } from "../data/tables";
6
+ import { MatchmakingInviteNotFoundError } from "../error/errors";
7
+
8
+ /**
9
+ * The direct-invite store — one player's pending asks to another. Create a pending invite; the invitee
10
+ * accepts (a session is minted and recorded) or declines. See {@link inviteStore}.
11
+ *
12
+ * Round-trip rule (CLAUDE.md §Data layer): read rows with `Invite.parse` (decode), write with
13
+ * `Invite.encode` (encode). No raw epoch numbers below.
14
+ */
15
+
16
+ /** What creating an invite needs. */
17
+ export interface CreateInviteInput {
18
+ id: string;
19
+ gameKey: string;
20
+ inviterId: string;
21
+ inviteeId: string;
22
+ at: Date;
23
+ }
24
+
25
+ export interface InviteStore {
26
+ /** Persist a new pending invite. */
27
+ create(input: CreateInviteInput): Promise<Invite>;
28
+ /** Fetch an invite by id, or undefined. */
29
+ get(id: string): Promise<Invite | undefined>;
30
+ /** Mark an invite accepted and record the minted session id. Throws `matchmaking/invite_not_found`. */
31
+ accept(id: string, sessionId: string, at: Date): Promise<Invite>;
32
+ /** Mark an invite declined. Throws `matchmaking/invite_not_found`. */
33
+ decline(id: string, at: Date): Promise<Invite>;
34
+ /** Every pending invite addressed to `userId`. */
35
+ pendingFor(userId: string): Promise<Invite[]>;
36
+ }
37
+
38
+ export function inviteStore(db: MatchmakingDatabase): InviteStore {
39
+ const get = async (id: string): Promise<Invite | undefined> => {
40
+ const row = await db.selectFrom(MATCHMAKING_INVITES_TABLE).where("id", "=", id).selectAll().executeTakeFirst();
41
+ return row ? Invite.parse(row) : undefined;
42
+ };
43
+
44
+ const loadOrThrow = async (id: string): Promise<Invite> => {
45
+ const invite = await get(id);
46
+ if (!invite) {
47
+ throw new MatchmakingInviteNotFoundError({ detail: `No invite with id ${id}.` });
48
+ }
49
+ return invite;
50
+ };
51
+
52
+ return {
53
+ async create(input: CreateInviteInput): Promise<Invite> {
54
+ const invite: Invite = {
55
+ id: input.id,
56
+ gameKey: input.gameKey,
57
+ inviterId: input.inviterId,
58
+ inviteeId: input.inviteeId,
59
+ status: "pending",
60
+ sessionId: null,
61
+ createdAt: input.at,
62
+ respondedAt: null,
63
+ };
64
+ await db.insertInto(MATCHMAKING_INVITES_TABLE).values(Invite.encode(invite)).execute();
65
+ return invite;
66
+ },
67
+
68
+ get,
69
+
70
+ async accept(id: string, sessionId: string, at: Date): Promise<Invite> {
71
+ const invite = await loadOrThrow(id);
72
+ const updated: Invite = { ...invite, status: "accepted", sessionId, respondedAt: at };
73
+ const record = Invite.encode(updated);
74
+ await db
75
+ .updateTable(MATCHMAKING_INVITES_TABLE)
76
+ .set({ status: record.status, sessionId: record.sessionId, respondedAt: record.respondedAt })
77
+ .where("id", "=", id)
78
+ .execute();
79
+ return updated;
80
+ },
81
+
82
+ async decline(id: string, at: Date): Promise<Invite> {
83
+ const invite = await loadOrThrow(id);
84
+ const updated: Invite = { ...invite, status: "declined", respondedAt: at };
85
+ const record = Invite.encode(updated);
86
+ await db
87
+ .updateTable(MATCHMAKING_INVITES_TABLE)
88
+ .set({ status: record.status, respondedAt: record.respondedAt })
89
+ .where("id", "=", id)
90
+ .execute();
91
+ return updated;
92
+ },
93
+
94
+ async pendingFor(userId: string): Promise<Invite[]> {
95
+ const rows = await db
96
+ .selectFrom(MATCHMAKING_INVITES_TABLE)
97
+ .where("inviteeId", "=", userId)
98
+ .where("status", "=", "pending")
99
+ .orderBy("createdAt", "desc")
100
+ .selectAll()
101
+ .execute();
102
+ return rows.map((row) => Invite.parse(row));
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { KVNamespace } from "@cloudflare/workers-types";
5
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { TypedKv } from "@pithy-sh/core/src/kv/kv";
7
+ import { z } from "zod";
8
+
9
+ /**
10
+ * A room — a short, shareable join code pointing at an already-minted multiplayer session. Stored in KV
11
+ * (namespace `MATCHMAKING`) under `matchmaking:<code>`, with a TTL so it expires, and a `usesRemaining`
12
+ * counter so it is limited-use. Zod-validated on every read and write.
13
+ */
14
+ export const Room = z
15
+ .object({
16
+ code: z.string().describe("The room's shareable code, e.g. WXYZ-1234."),
17
+ gameKey: z.string().describe("The matchmaking game the room is for."),
18
+ hostId: z.string().describe("The authenticated user who opened the room (the session's creator)."),
19
+ sessionId: z.string().describe("The multiplayer session id the room's code joins players into."),
20
+ usesRemaining: z
21
+ .number()
22
+ .int()
23
+ .nonnegative()
24
+ .describe("How many redemptions the code has left before it is spent."),
25
+ createdAt: JsonDate.describe("When the room was opened."),
26
+ expiresAt: JsonDate.describe(
27
+ "When the code expires — used to recompute the KV TTL on each redemption without resetting the window.",
28
+ ),
29
+ })
30
+ .describe("A room-code entry: a limited-use, short-lived pointer to a multiplayer session.");
31
+ export type Room = z.output<typeof Room>;
32
+
33
+ /** The KV key for a room — its code. Physical key: `matchmaking:<code>`. */
34
+ export const RoomKey = z.object({ code: z.string().describe("The room code (the key).") }).describe("Room KV key.");
35
+
36
+ /** The fixed KV namespace prefix for room entries. */
37
+ export const ROOM_PREFIX = "matchmaking";
38
+
39
+ /** A typed, Zod-validated KV view of the room store over the `MATCHMAKING` namespace. */
40
+ export function roomStore(namespace: KVNamespace, ttlSeconds: number): TypedKv<typeof Room, typeof RoomKey> {
41
+ return new TypedKv(namespace, { prefix: ROOM_PREFIX, key: RoomKey, value: Room, ttlSeconds });
42
+ }