From 05a3fee87a0606810a247a06502ed727fcf8dd39 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 10 Oct 2026 11:19:20 +0300 Subject: [PATCH] feat(session): list your runs that have not stopped Asking for a run on a busy box is refused, but the refusal does not say what holds the box. A client that just lost its connection needs to know whether the run in the way is the game it asked for, which it can rejoin, or another one it should offer to stop. GET /session answers that. --- apps/api/app/routes/session.ts | 21 ++++++++++++++++++ packages/core/src/session/index.ts | 27 +++++++++++++++++++++++ packages/core/src/session/session.test.ts | 27 +++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index c5fbc98e..220316e8 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -92,6 +92,27 @@ export namespace SessionApi { .strict(); export const route = new Hono() + .get( + '/', + notPublic, + describeRoute({ + tags: ['Session'], + summary: 'Your runs that have not stopped', + description: + 'At most one per box, newest first. Read this when asking for a run is refused because a box is busy: the run holding it may be the same game, which a client can rejoin by polling it, or another one it can offer to stop.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Session.Info.array()) } }, + description: 'The runs still going, or still asked for' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + async (c) => { + return c.json({ data: await Session.activeForOwner(actingPerson()) }); + } + ) .post( '/', notPublic, diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 3c2ff697..42d6792b 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -513,6 +513,33 @@ export namespace Session { }); }); + /** + * The runs of someone's boxes that have not stopped, newest first. + * + * At most one per box, by the same index {@link activeForBox} leans on. A + * client reads this to tell "your box is busy" apart from "your box is + * busy with the game you just asked for", which it can rejoin rather than + * stop. + */ + export const activeForOwner = fn(z.string(), async (userId) => { + return Database.use(async (tx) => { + return tx + .select({ session: SessionTable }) + .from(SessionTable) + .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) + .where( + and( + eq(BoxTable.userId, userId), + isNull(SessionTable.timeDeleted), + isNull(SessionTable.timeStopped), + isNull(BoxTable.timeDeleted) + ) + ) + .orderBy(desc(SessionTable.timeCreated)) + .then((rows) => rows.map((row) => serialize(row.session))); + }); + }); + /** * The owner asks for a run to end. * diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts index 38482565..46fcdcf4 100644 --- a/packages/core/src/session/session.test.ts +++ b/packages/core/src/session/session.test.ts @@ -186,6 +186,33 @@ describe('Session', () => { expect((await Session.activeForBox(box.id))?.id).toBe(second.id); }); + test('an owner sees their own runs that have not stopped, and nobody else’s', async () => { + const mine = await scene('ses-owner-a', 5406); + const theirs = await scene('ses-owner-b', 5407); + const ended = await Session.create({ + id: Identifier.ascending('session'), + boxId: mine.box.id, + gameId: mine.gameId, + linkedAccountId: mine.owner.linkedAccountId + }); + await Session.setState({ id: ended.id, state: 'ended', errorMessage: null }); + const going = await Session.create({ + id: Identifier.ascending('session'), + boxId: mine.box.id, + gameId: mine.gameId, + linkedAccountId: mine.owner.linkedAccountId + }); + await Session.create({ + id: Identifier.ascending('session'), + boxId: theirs.box.id, + gameId: theirs.gameId, + linkedAccountId: theirs.owner.linkedAccountId + }); + + const active = await Session.activeForOwner(mine.owner.userId); + expect(active.map((s) => s.id)).toEqual([going.id]); + }); + test('deleting a box takes its sessions with it', async () => { const { owner, box, gameId } = await scene('ses-cascade', 5405); await Session.create({