mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
release: library downloads only from your boxes' hosts, named by GPU (#384)
GET /library picked each game's most recent download row from any host, so one person's library could show another person's machine id and progress. It now reads only hosts the caller's boxes are on. Boxes and library downloads gain `gpu`, the host's first reported card model, so clients name a place by hardware rather than a machine id. Null until a host reports one. Tests: core 240 pass (3 new), api 171 pass. The leak test was checked to fail with the host filter removed.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<typeof Info> {
|
||||
export function serialize(input: typeof BoxTable.$inferSelect, gpu: string | null = null): z.infer<typeof Info> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -59,6 +59,15 @@ export namespace Machine {
|
||||
|
||||
export type Gpu = z.infer<typeof Gpu>;
|
||||
|
||||
/**
|
||||
* 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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user