diff --git a/packages/core/src/box/box.test.ts b/packages/core/src/box/box.test.ts index 533598f9..46d4bb5e 100644 --- a/packages/core/src/box/box.test.ts +++ b/packages/core/src/box/box.test.ts @@ -3,6 +3,7 @@ import { afterAll, describe, expect, test } from 'bun:test'; import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; import { Identifier } from '../id.js'; +import { Machine } from '../machine/index.js'; import { Box } from './index.js'; const sql = testDb(); @@ -27,6 +28,16 @@ afterAll(async () => { }); describe('Box', () => { + test("a person's boxes say which card they run on, and nothing until the host reports one", async () => { + const o = await newOwner('box-gpu'); + const machineId = await Fixtures.machine(o, 'box-gpu-host'); + await Box.create({ id: Identifier.ascending('box'), userId: o.userId, machineId, label: 'gpu', tier: 'sm' }); + expect((await Box.listByUser(o.userId))[0]?.gpu).toBeNull(); + + await Machine.setGpus({ id: machineId, gpus: [{ model: 'AMD Radeon RX 9060 XT', pciId: '1002:7590' }] }); + expect((await Box.listByUser(o.userId))[0]?.gpu).toBe('AMD Radeon RX 9060 XT'); + }); + test('a new box starts created, sm, and with nothing to explain', async () => { const owner = await newOwner('box-defaults'); const machineId = await Fixtures.machine(owner); diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index 5c416fac..e0b14148 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -4,6 +4,7 @@ import z from 'zod'; import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; +import { Machine } from '../machine/index.js'; import { MachineTable } from '../machine/machine.sql.js'; import { BoxState, BoxTable, BoxTier } from './box.sql.js'; import { Placement } from './placement.js'; @@ -50,6 +51,11 @@ export namespace Box { stopClean: z.boolean().nullable().optional().meta({ description: 'Whether that stop was clean. Null if it never ran', example: Examples.Box.stopClean + }), + gpu: z.string().nullable().optional().meta({ + description: + 'The card it runs on, as its host reports it: what a person recognises the box by. Null until the host has reported one', + example: Examples.Box.gpu }) }) .meta({ @@ -118,11 +124,12 @@ export namespace Box { export const listByUser = fn(Info.shape.userId, async (userId) => { return Database.use(async (tx) => { return tx - .select() + .select({ box: BoxTable, gpus: MachineTable.gpus }) .from(BoxTable) + .leftJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId)) .where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted))) .orderBy(BoxTable.timeCreated) - .then((rows) => rows.map(serialize)); + .then((rows) => rows.map((r) => serialize(r.box, Machine.gpuName(r.gpus)))); }); }); @@ -160,7 +167,7 @@ export namespace Box { .from(BoxTable) .where(and(eq(BoxTable.machineId, machineId), isNull(BoxTable.timeDeleted))) .orderBy(BoxTable.timeCreated) - .then((rows) => rows.map(serialize)); + .then((rows) => rows.map((row) => serialize(row))); }); }); @@ -378,7 +385,7 @@ export namespace Box { } ); - export function serialize(input: typeof BoxTable.$inferSelect): z.infer { + export function serialize(input: typeof BoxTable.$inferSelect, gpu: string | null = null): z.infer { return { id: input.id, userId: input.userId, @@ -387,7 +394,8 @@ export namespace Box { tier: input.tier as Info['tier'], state: input.state as Info['state'], stopReason: input.stopReason, - stopClean: input.stopClean + stopClean: input.stopClean, + gpu }; } } diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index ea2e8d92..70ba3e16 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -161,7 +161,8 @@ export namespace Examples { tier: 'sm' as const, state: 'created' as const, stopReason: null, - stopClean: null + stopClean: null, + gpu: 'NVIDIA GeForce RTX 3060' }; export const Session = { @@ -188,7 +189,8 @@ export namespace Examples { totalBytes: 5000000000, timeStarted: '2026-07-28T12:00:00.000Z', timeCompleted: null, - errorMessage: null + errorMessage: null, + gpu: 'NVIDIA GeForce RTX 3060' }; export const WaitlistEntry = { diff --git a/packages/core/src/game/download.ts b/packages/core/src/game/download.ts index 2d629e92..e824d367 100644 --- a/packages/core/src/game/download.ts +++ b/packages/core/src/game/download.ts @@ -59,6 +59,11 @@ export namespace GameDownload { errorMessage: z.string().nullable().optional().meta({ description: 'Error message if status is failed', example: Examples.GameDownload.errorMessage + }), + gpu: z.string().nullable().optional().meta({ + description: + 'The card on the host doing the download, as the host reports it. Set where the caller is shown a place, null where it is not known', + example: Examples.GameDownload.gpu }) }) .meta({ diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 77c3c232..d04e8bc7 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -59,6 +59,15 @@ export namespace Machine { export type Gpu = z.infer; + /** + * What a person calls a host: its first card's model, as the host reported + * it. Null until a report names one — never a machine id, which nobody + * recognises ref(d-0081), and never a guess. + */ + export function gpuName(gpus: readonly { model: string }[] | null | undefined): string | null { + return gpus?.[0]?.model ?? null; + } + export const Info = z .object({ id: z.string().meta({ diff --git a/packages/core/src/user/library.test.ts b/packages/core/src/user/library.test.ts index aa271d37..10fa525a 100644 --- a/packages/core/src/user/library.test.ts +++ b/packages/core/src/user/library.test.ts @@ -1,6 +1,9 @@ import { afterAll, describe, expect, test } from 'bun:test'; +import { Box } from '../box/index.js'; import { Fixtures } from '../db/fixtures.js'; +import { GameDownload } from '../game/download.js'; +import { Machine } from '../machine/index.js'; import { testDb } from '../db/test.js'; import { Game } from '../game/index.js'; import { Identifier } from '../id.js'; @@ -28,7 +31,12 @@ async function own(userId: string, gameId: string) { } afterAll(async () => { + if (createdGameIds.length > 0) { + await sql`delete from "game_download" where game_id in ${sql(createdGameIds)}`; + } if (createdUserIds.length > 0) { + // box→machine is `restrict`: boxes go before the users their machines cascade from. + await sql`delete from "box" where user_id in ${sql(createdUserIds)}`; await sql`delete from "user_library" where user_id in ${sql(createdUserIds)}`; await sql`delete from "user" where id in ${sql(createdUserIds)}`; } @@ -63,3 +71,39 @@ describe('Library.prune', () => { expect((await Library.listByUser(owner.userId)).length).toBe(1); }); }); + +describe('Library.listByUserWithGames', () => { + test("shows a download only from a host the person's box is on, named by its card", async () => { + const me = await Fixtures.owner('library-mine'); + const them = await Fixtures.owner('library-theirs'); + createdUserIds.push(me.userId, them.userId); + const game = await newGame(9_100_004); + await own(me.userId, game); + + const mine = await Fixtures.machine(me, 'library-mine-host'); + const theirs = await Fixtures.machine(them, 'library-theirs-host'); + await Machine.setGpus({ id: mine, gpus: [{ model: 'NVIDIA GeForce RTX 3060', pciId: '10de:2504' }] }); + await Box.create({ id: Identifier.ascending('box'), userId: me.userId, machineId: mine, label: 'mine', tier: 'sm' }); + + // Someone else's host has it, and is the most recent: still not shown. + await GameDownload.upsertState({ hostId: mine, gameId: game, status: 'downloading' }); + await GameDownload.upsertState({ hostId: theirs, gameId: game, status: 'ready' }); + + const [entry] = await Library.listByUserWithGames(me.userId); + expect(entry?.download?.hostId).toBe(mine); + expect(entry?.download?.status).toBe('downloading'); + expect(entry?.download?.gpu).toBe('NVIDIA GeForce RTX 3060'); + }); + + test('with no box, no download is shown at all', async () => { + const me = await Fixtures.owner('library-boxless'); + const them = await Fixtures.owner('library-boxless-other'); + createdUserIds.push(me.userId, them.userId); + const game = await newGame(9_100_005); + await own(me.userId, game); + await GameDownload.upsertState({ hostId: await Fixtures.machine(them), gameId: game, status: 'ready' }); + + const [entry] = await Library.listByUserWithGames(me.userId); + expect(entry?.download).toBeNull(); + }); +}); diff --git a/packages/core/src/user/library.ts b/packages/core/src/user/library.ts index 8556c23e..6606d970 100644 --- a/packages/core/src/user/library.ts +++ b/packages/core/src/user/library.ts @@ -8,6 +8,9 @@ import { GameDownload } from '../game/download.js'; import { GameDownloadTable } from '../game/download.sql.js'; import { GameTable } from '../game/game.sql.js'; import { Game } from '../game/index.js'; +import { BoxTable } from '../box/box.sql.js'; +import { Machine } from '../machine/index.js'; +import { MachineTable } from '../machine/machine.sql.js'; import { UserLibraryTable } from './library.sql.js'; export namespace Library { @@ -147,14 +150,27 @@ export namespace Library { const gameIds = [ ...new Set(rows.filter((row) => row.game !== null).map((row) => row.library.gameId)) ]; + // Only the hosts this person's boxes are on. A download elsewhere is + // someone else's machine: its id and its progress are not theirs to + // see, and it is not where their game would run ref(d-0081). + const hosts = await tx + .selectDistinct({ id: MachineTable.id, gpus: MachineTable.gpus }) + .from(BoxTable) + .innerJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId)) + .where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted))); + const gpuByHost = new Map(hosts.map((h) => [h.id, Machine.gpuName(h.gpus)])); const downloadRows = - gameIds.length > 0 + gameIds.length > 0 && hosts.length > 0 ? await tx .select() .from(GameDownloadTable) .where( and( inArray(GameDownloadTable.gameId, gameIds), + inArray( + GameDownloadTable.hostId, + hosts.map((h) => h.id) + ), isNull(GameDownloadTable.timeDeleted) ) ) @@ -180,7 +196,9 @@ export namespace Library { playtime2w: row.library.playtime2w, playtimeForever: row.library.playtimeForever, lastPlayed: row.library.lastPlayed?.toISOString() ?? null, - download: download ? GameDownload.serialize(download) : null + download: download + ? { ...GameDownload.serialize(download), gpu: gpuByHost.get(download.hostId) ?? null } + : null }; }); });