From 0f301e5788f5a40e504f689de97b84148dbaf56d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 21 Jul 2026 01:19:37 -0400 Subject: [PATCH] relationship notifications (maybe) --- apps/api/src/images-db.ts | 8 +- apps/api/src/relationships-db.ts | 155 ++++++++++++++++------ apps/api/src/routes/images.ts | 2 +- apps/api/src/routes/social.ts | 77 ++++++++--- apps/api/src/test/integration/api.test.ts | 129 ++++++++++++++++-- apps/api/vitest.config.ts | 18 ++- 6 files changed, 312 insertions(+), 77 deletions(-) diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index c3711cd..2481cf4 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -37,10 +37,10 @@ export const SCHEMA_DDL: string[] = [ ] /** - * Saved-image categories from the C# `SavedImageType` enum — the value of a stored - * image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in - * the image data layer so both the upload route and the slideshow query share one - * definition. + * Saved-image categories from the reference's `SavedImageType` enum — the value of a + * stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives + * here in the image data layer so both the upload route and the slideshow query share + * one definition. */ export const SavedImageType = { None: 0, diff --git a/apps/api/src/relationships-db.ts b/apps/api/src/relationships-db.ts index 9fa3f79..945314f 100644 --- a/apps/api/src/relationships-db.ts +++ b/apps/api/src/relationships-db.ts @@ -14,7 +14,7 @@ * workers' migrations that share the database). */ -/** Relationship state from the perspective of the player asking (mirror of the C# enum). */ +/** Relationship state from the perspective of the player asking (mirrors the reference). */ export enum RelationshipType { None = 0, FriendRequestSent = 1, @@ -53,7 +53,7 @@ interface RelationshipRow { target_muted: number } -/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */ +/** The per-player relationship projection returned to the client (RelationshipResponse). */ export interface RelationshipResponse { Favorited: number Ignored: number @@ -62,6 +62,34 @@ export interface RelationshipResponse { RelationshipType: RelationshipType } +/** + * The result of a friend-graph mutation. These changes are visible to BOTH players, and + * each sees a different projection of the same row (the target of a request sees + * `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for + * the HTTP response and the acting player's notification, `other` for the target's. + * + * `changed` is false when the mutation was a no-op: re-sending a request that's already + * outstanding, befriending someone you're already friends with, accepting something that + * isn't pending. Nothing was written, so no RelationshipChanged notification should go out + * (the reference server is likewise silent on its no-change branch). + */ +export interface RelationshipChange { + self: RelationshipResponse + other: RelationshipResponse + changed: boolean +} + +/** The projection reported for a pair with no stored relationship. */ +function noneResponse(otherId: number): RelationshipResponse { + return { + PlayerID: otherId, + RelationshipType: RelationshipType.None, + Favorited: 0, + Ignored: 0, + Muted: 0, + } +} + /** Flip a pending request to the other side's point of view; Friend/None are symmetric. */ function flipType(type: number): RelationshipType { if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived @@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons } } +/** Project a written row for both players in the pair. */ +function toChange( + row: RelationshipRow, + playerId: number, + otherId: number, + changed: boolean +): RelationshipChange { + return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed } +} + /** Find the single row for an unordered pair (either direction), or null. */ async function findPair(db: D1Database, a: number, b: number): Promise { return db @@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise() - return results - .filter((row) => row.relationship_type !== RelationshipType.None) - .map((row) => toResponse(row, playerId)) + return results.map((row) => toResponse(row, playerId)) } /** @@ -121,14 +160,14 @@ export async function getRelationshipsForPlayer( * requester. Inserts a new row or, if one already exists for the pair (either * direction), rewrites it so the requester is normalized to `requesterId` and * the flags are preserved for whichever side each player is on. Returns the - * relationship from `requesterId`'s point of view. + * row as written, for the caller to project onto whichever side it needs. */ async function upsertPair( db: D1Database, requesterId: number, targetId: number, type: RelationshipType -): Promise { +): Promise { const existing = await findPair(db, requesterId, targetId) if (!existing) { await db @@ -138,7 +177,17 @@ async function upsertPair( ) .bind(requesterId, targetId, type) .run() - return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 } + return { + requester_id: requesterId, + target_id: targetId, + relationship_type: type, + requester_favorited: 0, + requester_ignored: 0, + requester_muted: 0, + target_favorited: 0, + target_ignored: 0, + target_muted: 0, + } } // Keep each player's flags with that player as the row is normalized to @@ -175,93 +224,117 @@ async function upsertPair( ) .run() return { - PlayerID: targetId, - RelationshipType: type, - Favorited: reqFlags.favorited, - Ignored: reqFlags.ignored, - Muted: reqFlags.muted, + requester_id: requesterId, + target_id: targetId, + relationship_type: type, + requester_favorited: reqFlags.favorited, + requester_ignored: reqFlags.ignored, + requester_muted: reqFlags.muted, + target_favorited: tgtFlags.favorited, + target_ignored: tgtFlags.ignored, + target_muted: tgtFlags.muted, } } /** * Send a friend request from `requesterId` to `targetId`. If the target already * has a pending request out to the requester, the two become friends instead - * (the request crosses an existing one). Already-friends is left unchanged. - * Returns the relationship from the requester's point of view. + * (the request crosses an existing one). Already-friends, and re-sending a request + * that's already outstanding, are no-ops. */ export async function sendFriendRequest( db: D1Database, requesterId: number, targetId: number -): Promise { +): Promise { const existing = await findPair(db, requesterId, targetId) if (existing) { - if (existing.relationship_type === RelationshipType.Friend) { - return toResponse(existing, requesterId) + // Already friends, or we already have a request out to them — nothing to write. + if ( + existing.relationship_type === RelationshipType.Friend || + (existing.requester_id === requesterId && + existing.relationship_type === RelationshipType.FriendRequestSent) + ) { + return toChange(existing, requesterId, targetId, false) } // The target already requested us → crossing requests become a friendship. if ( existing.requester_id === targetId && existing.relationship_type === RelationshipType.FriendRequestSent ) { - return upsertPair(db, requesterId, targetId, RelationshipType.Friend) + const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend) + return toChange(row, requesterId, targetId, true) } } - return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent) + const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent) + return toChange(row, requesterId, targetId, true) } /** * `accepterId` accepts a pending friend request from `otherId`. Only upgrades to - * Friend when a request from `otherId` is actually pending; otherwise the - * current state is returned unchanged. Returns the relationship from the - * accepter's point of view. + * Friend when a request from `otherId` is actually pending; otherwise the current + * state is returned as a no-op. (The reference server answers 403 there instead; + * we stay lenient, but either way nothing changed.) */ export async function acceptFriendRequest( db: D1Database, accepterId: number, otherId: number -): Promise { +): Promise { const existing = await findPair(db, accepterId, otherId) if ( existing && existing.requester_id === otherId && existing.relationship_type === RelationshipType.FriendRequestSent ) { - // upsertPair projects for the requester (otherId); the accepter is the target, - // so re-project the written row from the accepter's point of view. - await upsertPair(db, otherId, accepterId, RelationshipType.Friend) - const updated = await findPair(db, accepterId, otherId) - if (updated) return toResponse(updated, accepterId) + const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend) + return toChange(row, accepterId, otherId, true) } return existing - ? toResponse(existing, accepterId) - : { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 } + ? toChange(existing, accepterId, otherId, false) + : { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false } } /** * Directly make `requesterId` and `targetId` friends (no pending request step). - * Returns the relationship from the requester's point of view. */ export async function addFriend( db: D1Database, requesterId: number, targetId: number -): Promise { - return upsertPair(db, requesterId, targetId, RelationshipType.Friend) +): Promise { + const existing = await findPair(db, requesterId, targetId) + if (existing && existing.relationship_type === RelationshipType.Friend) { + return toChange(existing, requesterId, targetId, false) + } + const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend) + return toChange(row, requesterId, targetId, true) } /** * Remove any relationship between the two players (unfriend / cancel request / - * decline). Deletes the row entirely so neither side reports a relationship. + * decline). + * + * The row is set to `None` rather than deleted, matching the reference server: the + * per-player favorited/ignored/muted flags live on that row and must survive an + * unfriending (someone you ignored stays ignored after you drop them as a friend). */ -export async function removeFriend(db: D1Database, a: number, b: number): Promise { +export async function removeFriend( + db: D1Database, + playerId: number, + otherId: number +): Promise { await db .prepare( - `DELETE FROM relationship + `UPDATE relationship SET relationship_type = ?3 WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)` ) - .bind(a, b) + .bind(playerId, otherId, RelationshipType.None) .run() + const updated = await findPair(db, playerId, otherId) + return updated + ? toChange(updated, playerId, otherId, true) + : { self: noneResponse(otherId), other: noneResponse(playerId), changed: true } } /** A per-player relationship flag — each is stored on the player's own side of the row. */ @@ -308,7 +381,5 @@ export async function setRelationshipFlag( } const updated = await findPair(db, playerId, otherId) - return updated - ? toResponse(updated, playerId) - : { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 } + return updated ? toResponse(updated, playerId) : noneResponse(otherId) } diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 2aa8bae..22cfee0 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -39,7 +39,7 @@ export const imageRoutes = new Hono({ strict: false }) if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) const file = candidate - // `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`), + // `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`), // posted as a multipart field. It carries the metadata we record on the image // (savedImageType, roomId, accessibility, description, taggedPlayerIds, …). let meta: Record = {} diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 7ab1551..ae30d0d 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -14,7 +14,7 @@ import { import type { Context } from 'hono' import type { App } from '../context' -import type { RelationshipFlag } from '../relationships-db' +import type { RelationshipChange, RelationshipFlag, RelationshipResponse } from '../relationships-db' /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -23,20 +23,15 @@ const HUB_INSTANCE = 'global' const RELATIONSHIP_CHANGED = 1 /** - * Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the - * result to the client the way the Go server does: the resulting relationship rides a - * `RelationshipChanged` hub notification to the caller, and the HTTP body is just the - * `{ Success, Message }` ack. Hub failures are logged and swallowed — the DB write has - * already committed, so a hub hiccup must not fail the request. + * Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are + * logged and swallowed — the DB write has already committed, so a hub hiccup must not fail + * the request. */ -async function applyFlag( +async function notifyRelationship( c: Context, playerId: number, - otherId: number, - flag: RelationshipFlag, - value: boolean -): Promise { - const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value) + rel: RelationshipResponse +): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, @@ -49,6 +44,39 @@ async function applyFlag( error: err instanceof Error ? err.message : String(err), }) } +} + +/** + * Notify both players of a friend-graph change, each with the relationship projected from + * their own point of view — the target of a request sees `FriendRequestReceived` where the + * sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody. + */ +async function notifyBoth( + c: Context, + playerId: number, + otherId: number, + change: RelationshipChange +): Promise { + if (!change.changed) return + await notifyRelationship(c, playerId, change.self) + await notifyRelationship(c, otherId, change.other) +} + +/** + * Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are + * private to the caller's own side of the row, so only the caller is notified. The + * resulting relationship rides the notification and the HTTP body is just the + * `{ Success, Message }` ack. + */ +async function applyFlag( + c: Context, + playerId: number, + otherId: number, + flag: RelationshipFlag, + value: boolean +): Promise { + const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value) + await notifyRelationship(c, playerId, rel) return c.json({ Success: true, Message: '' }) } @@ -92,12 +120,19 @@ export const socialRoutes = new Hono({ strict: false }) // client calls this as a GET; the mutations accept GET or POST (the Go handlers // matched any method). Auth-gated. Returns the resulting relationship from the // caller's point of view. + // + // The four friend-graph mutations below change state both players can see, so each + // notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP + // response. A no-op — re-sending an outstanding request, accepting nothing pending — + // notifies nobody. .on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await sendFriendRequest(c.env.DB, id, target)) + const change = await sendFriendRequest(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) }) // Accept a pending friend request from another player (`?id=`). Auth-gated. @@ -106,17 +141,21 @@ export const socialRoutes = new Hono({ strict: false }) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await acceptFriendRequest(c.env.DB, id, target)) + const change = await acceptFriendRequest(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) }) - // Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated. + // Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as + // a None relationship so the per-side flags survive (see removeFriend). Auth-gated. .on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - await removeFriend(c.env.DB, id, target) - return c.json({ success: true }) + const change = await removeFriend(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) }) // Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated. @@ -125,7 +164,9 @@ export const socialRoutes = new Hono({ strict: false }) if (id === null) return unauthorized(c) const target = await targetPlayerId(c) if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return c.json(await addFriend(c.env.DB, id, target)) + const change = await addFriend(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) }) // Ignore / mute another player, and their inverses unignore / unmute (target diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b481cae..7d8ddb6 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1517,6 +1517,24 @@ describe('relationships', () => { // the relationship detail rides a RelationshipChanged hub notification instead. const ACK = { Success: true, Message: '' } + // The notify DO is stubbed to record every notifyPlayer call (see vitest.config). + type Notification = { + playerId: number + notificationType: number + data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number } + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + + /** Drop everything the hub stub has recorded so far. */ + async function resetNotifications() { + await hub().fetch('http://do/all', { method: 'DELETE' }) + } + + /** Every notification pushed since the last reset, in order. */ + async function sentNotifications(): Promise { + return (await (await hub().fetch('http://do/all')).json()) as Notification[] + } + // POST a flag mutation the real client way (form body `PlayerId=`), returning // the parsed ack body. async function ackFlag(path: string, sub: string, playerId: number) { @@ -1529,9 +1547,8 @@ describe('relationships', () => { ).json()) as { Success: boolean; Message: string } } - // A player's own-side flags read straight from the relationship row. The None row a - // flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the - // flag effect is verified here instead of through the (now ack-only) response. + // A player's own-side flags read straight from the relationship row — the flag + // endpoints return only an ack, so the effect is verified against the row itself. async function ownFlags(playerId: number, otherId: number) { const row = (await env.DB.prepare( `SELECT requester_id, requester_favorited, requester_ignored, requester_muted, @@ -1597,10 +1614,27 @@ describe('relationships', () => { { PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, ]) - // 500 removes → neither side has a relationship. + // 500 removes → both sides drop to None. The row is kept (that's where the + // per-side flags live), so v2/get still reports the pair, now as None (0). expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200) - expect(await relationships('500')).toEqual([]) - expect(await relationships('501')).toEqual([]) + expect(await relationships('500')).toEqual([ + { PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) + expect(await relationships('501')).toEqual([ + { PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) + }) + + test('removefriend keeps the caller’s ignore flag', async () => { + // 760 befriends 761 then ignores them; dropping the friendship must not + // un-ignore them (the flag lives on the row the removal downgrades to None). + await mutate('/api/relationships/v2/addfriend', '760', 761) + await ackFlag('/api/relationships/v1/ignore', '760', 761) + await mutate('/api/relationships/v2/removefriend', '760', 761) + expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 }) + expect(await relationships('760')).toEqual([ + { PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 }, + ]) }) test('addfriend makes them friends directly', async () => { @@ -1689,8 +1723,10 @@ describe('relationships', () => { // Mirrors ignore/mute: a bare None row is created with the caller's side flagged. expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK) expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 }) - // A None row is not reported as a relationship by v2/get. - expect(await relationships('730')).toEqual([]) + // The bare None row is reported by v2/get — it carries the flag. + expect(await relationships('730')).toEqual([ + { PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 }, + ]) }) test('a self-targeted favorite is rejected', async () => { @@ -1711,4 +1747,81 @@ describe('relationships', () => { expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 }) }) + + test('sendfriendrequest notifies both players with their own projection', async () => { + await resetNotifications() + await mutate('/api/relationships/v2/sendfriendrequest', '770', 771) + + // Both sides hear about it, each seeing the other player and their own side's + // type: the sender Sent (1), the recipient Received (2). + expect(await sentNotifications()).toEqual([ + { + playerId: 770, + notificationType: 1, + data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 }, + }, + { + playerId: 771, + notificationType: 1, + data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }, + }, + ]) + }) + + test('accepting notifies both players as Friend', async () => { + await mutate('/api/relationships/v2/sendfriendrequest', '780', 781) + await resetNotifications() + await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780) + + const sent = await sentNotifications() + expect(sent).toHaveLength(2) + // Friend (3) is symmetric, so both sides see the same type, each pointing at the other. + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + playerId: 780, + data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }), + }), + expect.objectContaining({ + playerId: 781, + data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }), + }), + ]) + ) + }) + + test('removefriend notifies both players with None', async () => { + await mutate('/api/relationships/v2/addfriend', '790', 791) + await resetNotifications() + await mutate('/api/relationships/v2/removefriend', '790', 791) + + const sent = await sentNotifications() + expect(sent).toHaveLength(2) + expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791]) + for (const n of sent) expect(n.data.RelationshipType).toBe(0) + }) + + test('a no-op friend request notifies nobody', async () => { + await mutate('/api/relationships/v2/sendfriendrequest', '810', 811) + await resetNotifications() + + // Re-sending an already-outstanding request writes nothing, so nothing is pushed. + await mutate('/api/relationships/v2/sendfriendrequest', '810', 811) + expect(await sentNotifications()).toEqual([]) + + // Likewise accepting something that isn't pending (810 has no request to accept). + await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811) + expect(await sentNotifications()).toEqual([]) + }) + + test('crossing requests notify both players as Friend', async () => { + await mutate('/api/relationships/v2/sendfriendrequest', '820', 821) + await resetNotifications() + // 821's request crosses 820's → an immediate friendship, both sides told. + await mutate('/api/relationships/v2/sendfriendrequest', '821', 820) + + const sent = await sentNotifications() + expect(sent).toHaveLength(2) + for (const n of sent) expect(n.data.RelationshipType).toBe(3) + }) }) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 29b394b..aefddd2 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -21,17 +21,27 @@ export default defineConfig({ compatibilityDate: '2026-06-16', compatibilityFlags: ['nodejs_compat'], durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' }, - // notifyPlayer records its last call so tests can assert the notification - // the worker pushed (type + payload); GET the DO to read it back. + // notifyPlayer records every call so tests can assert the notifications the + // worker pushed (type + payload). GET the DO for the most recent one, + // GET /all for the whole list (friend-graph changes notify both players), + // DELETE to reset it between assertions. script: ` import { DurableObject } from 'cloudflare:workers' export class NotificationsHub extends DurableObject { + sent = [] async notifyPlayer(playerId, notificationType, data) { - this.last = { playerId, notificationType, data } + this.sent.push({ playerId, notificationType, data }) return { delivered: 0, queued: true } } async broadcast() { return { delivered: 0 } } - async fetch() { return Response.json(this.last ?? null) } + async fetch(request) { + if (request.method === 'DELETE') { + this.sent = [] + return new Response(null, { status: 204 }) + } + if (new URL(request.url).pathname === '/all') return Response.json(this.sent) + return Response.json(this.sent.at(-1) ?? null) + } } export default { fetch() { return new Response('ok') } } `,