From 04bb25bf66c9bbabe8d7d19b24bad01e6727173d Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 19 Oct 2026 21:59:29 +0300 Subject: [PATCH 1/2] feat(api): rooms are GPUs and plans count rooms A box records the GPU it is pinned to. GET /location lists your own GPUs and the fleet's by model. POST /box takes { on: own, machineId, pciId } or { on: fleet, model }; free holds 1 cloud room on entry models, Pro 3 on any, own rooms are unlimited, and refusals are typed (room_limit, gpu_needs_pro). The weekend trial and the usage windows no longer decide anything and GET /billing reports plan and room counts. Cloud rooms with nothing live for 3 hours are stopped when their host polls for work. --- apps/api/app/index.ts | 2 + apps/api/app/routes/billing.ts | 16 +- apps/api/app/routes/box.ts | 59 +- apps/api/app/routes/location.ts | 32 + apps/api/app/routes/session.ts | 79 +- apps/api/test/fleet.test.ts | 5 +- apps/api/test/rooms.test.ts | 268 ++ apps/api/test/session.test.ts | 13 +- apps/api/test/trial.test.ts | 302 -- .../core/migrations/0029_box_gpu_pci_id.sql | 1 + .../core/migrations/meta/0029_snapshot.json | 4053 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/billing/index.ts | 127 +- packages/core/src/billing/rooms.ts | 67 + packages/core/src/box/box.sql.ts | 5 + packages/core/src/box/index.ts | 98 +- packages/core/src/box/location.ts | 77 + packages/core/src/box/placement.test.ts | 2 +- packages/core/src/box/placement.ts | 143 +- packages/core/src/env.ts | 6 + packages/core/src/error.ts | 6 +- packages/core/src/session/index.ts | 110 +- packages/core/src/trial/index.ts | 304 -- 23 files changed, 4938 insertions(+), 844 deletions(-) create mode 100644 apps/api/app/routes/location.ts create mode 100644 apps/api/test/rooms.test.ts delete mode 100644 apps/api/test/trial.test.ts create mode 100644 packages/core/migrations/0029_box_gpu_pci_id.sql create mode 100644 packages/core/migrations/meta/0029_snapshot.json create mode 100644 packages/core/src/billing/rooms.ts create mode 100644 packages/core/src/box/location.ts delete mode 100644 packages/core/src/trial/index.ts diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 0b38dae8..fb816faa 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -11,6 +11,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status'; import { auth } from './middleware/auth.js'; import { AccessTokenApi } from './routes/access-token.js'; import { BillingApi } from './routes/billing.js'; +import { LocationApi } from './routes/location'; import { BoxApi } from './routes/box.js'; import { EnrolmentApi } from './routes/enrolment.js'; import { GameApi } from './routes/game.js'; @@ -56,6 +57,7 @@ const routes = app .route('/machine', EnrolmentApi.route) .route('/team', TeamApi.route) .route('/box', BoxApi.route) + .route('/location', LocationApi.route) .route('/session', SessionApi.route) .route('/access-token', AccessTokenApi.route) .route('/waitlist', WaitlistApi.route) diff --git a/apps/api/app/routes/billing.ts b/apps/api/app/routes/billing.ts index bd1aa1fe..8e33f1e2 100644 --- a/apps/api/app/routes/billing.ts +++ b/apps/api/app/routes/billing.ts @@ -144,9 +144,9 @@ export namespace BillingApi { notPublic, describeRoute({ tags: ['Billing'], - summary: 'What you are on, and what you have spent', + summary: 'What you are on, and how many rooms it allows', description: - 'The plan, and where each of the three windows stands. This is what a meter is drawn from — the percentages here are the same numbers that decide whether a run may start, so a full bar and a refusal cannot disagree.', + 'The plan, and cloud rooms used against allowed. Rooms are the only limit a plan sets; there are no time windows.', responses: { 200: { content: { 'application/json': { schema: Result(Billing.State) } }, @@ -156,7 +156,13 @@ export namespace BillingApi { 404: ErrorResponses[404] } }), - async (c) => c.json({ data: await Billing.state({ teamId: await payingTeam() }) }) + async (c) => + c.json({ + data: await Billing.state({ + teamId: await payingTeam(), + userId: Actor.userID + }) + }) ) .post( '/checkout', @@ -232,7 +238,9 @@ export namespace BillingApi { }), async (c) => { mustBeConfigured(); - return c.json({ data: await Polar.portal({ teamId: await payingTeam() }) }); + return c.json({ + data: await Polar.portal({ teamId: await payingTeam() }) + }); } ); } diff --git a/apps/api/app/routes/box.ts b/apps/api/app/routes/box.ts index 81799ed2..d856d65b 100644 --- a/apps/api/app/routes/box.ts +++ b/apps/api/app/routes/box.ts @@ -2,10 +2,12 @@ import { Actor } from '@nestri/core/actor'; import { BoxTier } from '@nestri/core/box/box.sql'; import { Box } from '@nestri/core/box/index'; import { Placement } from '@nestri/core/box/placement'; -import { Env } from '@nestri/core/env'; +import { Billing } from '@nestri/core/billing/index'; +import { Rooms } from '@nestri/core/billing/rooms'; import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; import { Identifier } from '@nestri/core/id'; +import { Team } from '@nestri/core/team/index'; import { Hono } from 'hono'; import { describeRoute } from 'hono-openapi'; import { z } from 'zod'; @@ -25,7 +27,7 @@ export namespace BoxApi { tags: ['Box'], summary: 'Create a box', description: - 'A box on your own host (`on: "own"`, the default; refused unless you have exactly one) or on a Nestri GPU (`on: "fleet"`). You hold at most one box on Nestri GPUs; asking again is a 409.', + 'A room on a GPU. `{ on: "own", machineId, pciId }` puts it on one of your own GPUs (unlimited). `{ on: "fleet", model }` puts it on a Nestri GPU of that model; free allows 1 cloud room on entry models, Pro 3 on any. Over that, 403 with code `room_limit` or `gpu_needs_pro`. Without `machineId` an own room needs exactly one registered host, and a fleet room without `model` takes the least-loaded machine.', responses: { 201: { content: { 'application/json': { schema: Result(Box.Info) } }, @@ -33,6 +35,7 @@ export namespace BoxApi { }, 400: ErrorResponses[400], 401: ErrorResponses[401], + 403: ErrorResponses[403], 404: ErrorResponses[404], 409: ErrorResponses[409] } @@ -42,26 +45,51 @@ export namespace BoxApi { z.object({ label: z.string().trim().min(1).max(64).default('box'), tier: z.enum(BoxTier.enumValues).default('md'), - on: z.enum(['own', 'fleet']).default('own') + on: z.enum(['own', 'fleet']).default('own'), + machineId: z.string().optional().meta({ + description: 'With `on: "own"`: the host holding the GPU. Needs `pciId`' + }), + pciId: z.string().optional().meta({ + description: 'With `on: "own"`: the GPU on that host, as `GET /location` lists it' + }), + model: z.string().optional().meta({ + description: 'With `on: "fleet"`: the GPU model, as `GET /location` lists it' + }) }) ), async (c) => { - const { label, tier, on } = c.req.valid('json'); + const { label, tier, on, machineId, pciId, model } = c.req.valid('json'); const userId = Actor.userID; + if (on === 'own' && Boolean(machineId) !== Boolean(pciId)) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.MISSING_REQUIRED_FIELD, + 'Name both `machineId` and `pciId`, or neither', + machineId ? 'pciId' : 'machineId' + ); + } if (on === 'fleet') { - const organisationId = Env.get().FLEET_ORGANISATION_ID; - const held = organisationId ? await Box.onFleet({ userId, organisationId }) : null; - if (held) { - throw new VisibleError( - 'already_exists', - ErrorCodes.Validation.ALREADY_EXISTS, - `You already have a box on Nestri GPUs: ${held.id}` - ); + // Plans count cloud rooms; your own GPUs are never limited. + const team = await Team.personalFor(userId); + const plan = team?.plan ?? 'free'; + if (model && !Rooms.mayUseModel(plan, model)) { + throw Rooms.needsPro(model); + } + if ((await Billing.cloudRooms(userId)) >= Rooms.allowed(plan)) { + throw Rooms.atLimit(model ?? null, plan); } } + const placer = + on === 'fleet' + ? model + ? Placement.onFleetModel(model) + : Placement.fleet + : machineId && pciId + ? Placement.onOwn({ machineId, pciId }) + : Placement.onlyHost; const box = await Box.createPlaced( { id: Identifier.ascending('box'), userId, label, tier }, - on === 'fleet' ? Placement.fleet : Placement.onlyHost + placer ); return c.json({ data: box }, 201); } @@ -78,7 +106,10 @@ export namespace BoxApi { content: { 'application/json': { schema: Result( - z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] }) + z.array(Box.Info).meta({ + description: 'Your boxes', + example: [Examples.Box] + }) ) } }, diff --git a/apps/api/app/routes/location.ts b/apps/api/app/routes/location.ts new file mode 100644 index 00000000..964b79bf --- /dev/null +++ b/apps/api/app/routes/location.ts @@ -0,0 +1,32 @@ +import { Actor } from '@nestri/core/actor'; +import { Location } from '@nestri/core/box/location'; +import { Team } from '@nestri/core/team/index'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; + +import { ErrorResponses, notPublic, Result } from '../utils'; + +/** Where a room can be created. */ +export namespace LocationApi { + export const route = new Hono().use(notPublic).get( + '/', + describeRoute({ + tags: ['Location'], + summary: 'GPUs a room can go on', + description: + 'Your own GPUs, one entry per card your hosts report, and the Nestri GPUs grouped by model. For each model, `available` says whether one is free now, `entry` whether it is an entry model, and `allowed` whether your plan may create a room on it.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Location.Info) } }, + description: 'Your locations' + }, + 401: ErrorResponses[401] + } + }), + async (c) => { + const userId = Actor.userID; + const plan = (await Team.personalFor(userId))?.plan ?? 'free'; + return c.json({ data: await Location.list({ userId, plan }) }); + } + ); +} diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index ea0964cd..5909dc27 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -9,7 +9,6 @@ import { Identifier } from '@nestri/core/id'; import { Session } from '@nestri/core/session/index'; import { Launch } from '@nestri/core/session/launch'; import { Team } from '@nestri/core/team/index'; -import { Trial } from '@nestri/core/trial/index'; import { Library } from '@nestri/core/user/library'; import { LinkedAccount } from '@nestri/core/user/linked-account'; import { Hono } from 'hono'; @@ -127,7 +126,9 @@ export namespace SessionApi { '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()) } }, + content: { + 'application/json': { schema: Result(Session.Info.array()) } + }, description: 'The runs still going, or still asked for' }, 401: ErrorResponses[401], @@ -236,7 +237,10 @@ export namespace SessionApi { // The library is also a synced copy, so this refuses a game // bought since the last sync. Both gaps let a launch fail late; // neither is a reason to start runs already known to fail. - const owned = await Library.findByUserAndGame({ userId, gameId: game.id }); + const owned = await Library.findByUserAndGame({ + userId, + gameId: game.id + }); if (!owned) { throw new VisibleError( 'forbidden', @@ -283,38 +287,17 @@ export namespace SessionApi { conflict(Session.BOX_BUSY); } - // The allowance is checked here and nowhere later. A limit refuses - // the next run; it never stops one already going, so this is the - // only moment it may speak. The answer comes back rather than - // being discarded, because the caller has to be told what it will - // cost and what remains — and asking a second time would let the - // number shown and the number billed disagree. - // - // On rented GPUs without a plan, the free weekend decides instead, - // and the run is marked as a trial so it is counted, and stopped, - // by the trial's rules rather than the allowance's. + // Plans count rooms, not time: nothing here refuses a run for time + // spent. The standing is returned so a client can show it. const payer = await Billing.teamForBox(box.id); - const trial = - payer?.hostClass === 'fleet' && (await Team.fromID(payer.teamId))?.plan !== 'paid'; - if (trial) { - await Trial.assertMayStart({ teamId: payer.teamId, userId, linkedAccountId }); - } - const billing = - payer && !trial - ? await Billing.assertMayStart({ - teamId: payer.teamId, - nextTier: body.tier ?? payer.tier, - nextHostClass: payer.hostClass - }) - : null; + const billing = payer ? await Billing.state({ teamId: payer.teamId, userId }) : null; const session = await Session.request({ id: Identifier.ascending('session'), boxId: box.id, gameId: game.id, linkedAccountId, - tier: body.tier, - trial + tier: body.tier }); return c.json({ data: session, billing }, 201); } @@ -340,7 +323,10 @@ export namespace SessionApi { validator( 'param', z.object({ - id: z.string().meta({ description: 'The run to read', example: Examples.Session.id }) + id: z.string().meta({ + description: 'The run to read', + example: Examples.Session.id + }) }) ), async (c) => { @@ -497,7 +483,9 @@ export namespace SessionApi { }), validator( 'param', - z.object({ id: z.string().meta({ description: 'The run', example: Examples.Session.id }) }) + z.object({ + id: z.string().meta({ description: 'The run', example: Examples.Session.id }) + }) ), validator( 'json', @@ -521,7 +509,10 @@ export namespace SessionApi { ); } // The same check, and the same gaps, as asking for a run. - const owned = await Library.findByUserAndGame({ userId, gameId: game.id }); + const owned = await Library.findByUserAndGame({ + userId, + gameId: game.id + }); if (!owned) { throw new VisibleError( 'forbidden', @@ -574,9 +565,10 @@ export namespace SessionApi { 'param', z.object({ id: z.string().meta({ description: 'The run', example: Examples.Session.id }), - launchId: z - .string() - .meta({ description: 'The launch to end', example: Examples.Launch.id }) + launchId: z.string().meta({ + description: 'The launch to end', + example: Examples.Launch.id + }) }) ), async (c) => { @@ -738,7 +730,9 @@ export namespace SessionApi { 'Returns the work for the calling host, and only that host — it comes from its own credentials and the scope is the query, so a box cannot see work for another. `session.start` is a run waiting to be started; `session.stop` is a run to end now, because its owner asked, its last launch is over, or the terms it started under ran out, repeated on every poll until the host reports it ended. `launch.start` is another game to start in a box that is already live; `launch.stop` is one launch to end, both repeated until the host reports that launch. An agent skips a kind it does not know. With `wait`, a call that finds nothing is held open until work appears or the wait is up, then answers empty, so a host is handed work within a moment of it being asked for; poll again straight away. Add `seen` and a call is held, too, while all the work it would answer is work the host already has, since a stop is repeated until reported. An API that cannot hold a call answers at once whatever `wait` says, and then the cadence the heartbeat hands down is the one to poll at.', responses: { 200: { - content: { 'application/json': { schema: Result(z.array(Session.Job)) } }, + content: { + 'application/json': { schema: Result(z.array(Session.Job)) } + }, description: 'Work for this host: runs to end first, then runs to start, oldest first' }, 403: ErrorResponses[403] @@ -757,16 +751,11 @@ export namespace SessionApi { description: `Seconds to hold the call open when there is no work, at most ${MAX_WAIT_SECONDS}. A larger value is held for the most; absent or 0 answers at once`, example: 25 }), - seen: z - .string() - .max(4096) - .optional() - .catch(undefined) - .meta({ - description: - 'Comma-separated keys of the jobs the host already has, from its last answer: `kind/sessionId`, and `kind/sessionId/launchId` for a launch job. With `wait`, work whose key is listed is not new, so a call is held while all of it is; at the end of the wait the answer is the full list. Absent, every job is new', - example: 'session.stop/ses_1,launch.stop/ses_1/lch_1' - }) + seen: z.string().max(4096).optional().catch(undefined).meta({ + description: + 'Comma-separated keys of the jobs the host already has, from its last answer: `kind/sessionId`, and `kind/sessionId/launchId` for a launch job. With `wait`, work whose key is listed is not new, so a call is held while all of it is; at the end of the wait the answer is the full list. Absent, every job is new', + example: 'session.stop/ses_1,launch.stop/ses_1/lch_1' + }) }) ), async (c) => { diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts index cbb8ab37..9bc707a3 100644 --- a/apps/api/test/fleet.test.ts +++ b/apps/api/test/fleet.test.ts @@ -129,13 +129,14 @@ describe('POST /box on the fleet', () => { expect(((await res.json()) as any).message).toContain('not available'); }); - test('one fleet box each: asking again is a 409', async () => { + test('a free plan holds one cloud room: asking again is refused with room_limit', async () => { const f = await fleet(1); useFleet(f.organisationId); const p = await person('fleet-twice'); expect((await createBox(p.headers, { on: 'fleet' })).status).toBe(201); const again = await createBox(p.headers, { on: 'fleet' }); - expect(again.status).toBe(409); + expect(again.status).toBe(403); + expect(((await again.json()) as any).code).toBe('room_limit'); expect((await Box.listByUser(p.owner.userId)).length).toBe(1); }); diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts new file mode 100644 index 00000000..9f388526 --- /dev/null +++ b/apps/api/test/rooms.test.ts @@ -0,0 +1,268 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { AccessToken } from '@nestri/core/access-token/index'; +import { Box } from '@nestri/core/box/index'; +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Env } from '@nestri/core/env'; +import { Identifier } from '@nestri/core/id'; +import { Machine } from '@nestri/core/machine/index'; +import { Team } from '@nestri/core/team/index'; + +import { app } from '../app/index'; +import { TEST_FRONTEND_URL } from './setup'; + +const sql = testDb(); +const createdUserIds: string[] = []; +const createdOrgIds: string[] = []; + +const V710 = { model: 'AMD Radeon Pro V710', vramMib: 28672, pciId: '1002:7461' }; +const BIG = { model: 'NVIDIA H100', vramMib: 81920, pciId: '10de:2330' }; + +function useFleet(organisationId: string, entry?: string) { + Env.init({ + NODE_ENV: 'test', + FRONTEND_URL: TEST_FRONTEND_URL, + FLEET_ORGANISATION_ID: organisationId, + ...(entry !== undefined ? { FLEET_ENTRY_GPUS: entry } : {}) + }); +} + +async function person(label: string, plan?: 'paid') { + const owner = await Fixtures.owner(label); + createdUserIds.push(owner.userId); + if (plan) { + await Team.setPlan({ id: owner.teamId, plan, subscriptionStatus: 'active' }); + } + const pat = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: owner.userId, + teamId: null, + name: label + }); + return { + owner, + headers: { + authorization: `Bearer ${pat.token}`, + 'content-type': 'application/json' + } as Record + }; +} + +async function fleet(gpusPerMachine: (typeof V710)[][]) { + const operator = await Fixtures.owner('operator'); + createdUserIds.push(operator.userId); + const f = await Fixtures.fleet(operator, gpusPerMachine.length); + createdOrgIds.push(f.organisationId); + for (const [i, gpus] of gpusPerMachine.entries()) { + await Machine.setGpus({ id: f.machines[i]!.id, gpus }); + } + useFleet(f.organisationId); + return f; +} + +const post = (headers: Record, body: object) => + app.request('/box', { method: 'POST', headers, body: JSON.stringify(body) }); +const json = async (res: Response) => (await res.json()) as any; + +afterAll(async () => { + await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`; + await sql`delete from box where user_id = any(${createdUserIds})`; + await sql`delete from install_token where created_by_user_id = any(${createdUserIds})`; + await sql`delete from machine where owner_user_id = any(${createdUserIds})`; + await sql`delete from organisation where id = any(${createdOrgIds})`; + await sql`delete from "user" where id = any(${createdUserIds})`; +}); + +describe('GET /location', () => { + test('lists your own GPUs one by one and the fleet grouped by model, per your plan', async () => { + await fleet([[V710], [V710], [BIG]]); + const free = await person('loc-free'); + const host = await Fixtures.machine(free.owner, 'loc-host'); + await Machine.setGpus({ + id: host, + gpus: [ + { model: 'NVIDIA GeForce RTX 3060', vramMib: 12288, pciId: '10de:2503' }, + { model: 'AMD Radeon RX 9060 XT', vramMib: 16304, pciId: '1002:7590' } + ] + }); + + const res = await app.request('/location', { headers: free.headers }); + expect(res.status).toBe(200); + const { data } = await json(res); + expect(data.own.map((o: any) => [o.machineId, o.pciId, o.model, o.vramMib, o.online])).toEqual([ + [host, '10de:2503', 'NVIDIA GeForce RTX 3060', 12288, false], + [host, '1002:7590', 'AMD Radeon RX 9060 XT', 16304, false] + ]); + expect(data.fleet).toEqual([ + { model: V710.model, vramMib: 28672, available: true, entry: true, allowed: true }, + { model: BIG.model, vramMib: 81920, available: true, entry: false, allowed: false } + ]); + + const pro = await person('loc-pro', 'paid'); + const proFleet = (await json(await app.request('/location', { headers: pro.headers }))).data + .fleet; + expect(proFleet.every((f: any) => f.allowed)).toBe(true); + expect(proFleet.find((f: any) => f.model === BIG.model).entry).toBe(false); + }); + + test('a model with every card taken is listed as not available', async () => { + await fleet([[V710]]); + const p = await person('loc-taken'); + expect((await post(p.headers, { on: 'fleet', model: V710.model })).status).toBe(201); + const other = await person('loc-other', 'paid'); + const { data } = await json(await app.request('/location', { headers: other.headers })); + expect(data.fleet[0].available).toBe(false); + }); +}); + +describe('POST /box on a chosen GPU', () => { + test('an own room is pinned to the chosen GPU; several own hosts are fine', async () => { + const p = await person('own-gpu'); + const a = await Fixtures.machine(p.owner, 'host-a'); + const b = await Fixtures.machine(p.owner, 'host-b'); + await Machine.setGpus({ id: a, gpus: [V710] }); + await Machine.setGpus({ id: b, gpus: [BIG, V710] }); + + const res = await post(p.headers, { on: 'own', machineId: b, pciId: BIG.pciId }); + expect(res.status).toBe(201); + const box = (await json(res)).data; + expect(box.machineId).toBe(b); + expect(box.gpuPciId).toBe(BIG.pciId); + expect((await Box.fromID(box.id))!.gpuPciId).toBe(BIG.pciId); + + // Own rooms are never counted against a plan. + for (let i = 0; i < 4; i++) { + expect((await post(p.headers, { on: 'own', machineId: a, pciId: V710.pciId })).status).toBe( + 201 + ); + } + }); + + test('an own room needs a GPU the host reports, on a host that is yours', async () => { + const p = await person('own-bad'); + const stranger = await person('own-stranger'); + const mine = await Fixtures.machine(p.owner, 'mine'); + const theirs = await Fixtures.machine(stranger.owner, 'theirs'); + await Machine.setGpus({ id: mine, gpus: [V710] }); + await Machine.setGpus({ id: theirs, gpus: [V710] }); + + expect((await post(p.headers, { on: 'own', machineId: mine, pciId: BIG.pciId })).status).toBe( + 404 + ); + expect((await post(p.headers, { on: 'own', machineId: theirs, pciId: V710.pciId })).status).toBe( + 404 + ); + expect((await post(p.headers, { on: 'own', machineId: mine })).status).toBe(400); + }); + + test('the old body still works: one host, no GPU named', async () => { + const p = await person('old-body'); + const host = await Fixtures.machine(p.owner, 'old'); + const res = await post(p.headers, { label: 'den' }); + expect(res.status).toBe(201); + const box = (await json(res)).data; + expect(box.machineId).toBe(host); + expect(box.gpuPciId).toBeNull(); + }); + + test('a fleet room is placed by model on the least-loaded machine with one free', async () => { + const f = await fleet([[V710], [V710]]); + const a = await person('fleet-model-a'); + const b = await person('fleet-model-b'); + const boxA = (await json(await post(a.headers, { on: 'fleet', model: V710.model }))).data; + const boxB = (await json(await post(b.headers, { on: 'fleet', model: V710.model }))).data; + expect(boxA.gpuPciId).toBe(V710.pciId); + expect(new Set([boxA.machineId, boxB.machineId])).toEqual(new Set(f.machines.map((m) => m.id))); + + // Both cards are now taken. + const c = await person('fleet-model-c'); + expect((await post(c.headers, { on: 'fleet', model: V710.model })).status).toBe(429); + }); + + test('free: one cloud room, entry models only', async () => { + await fleet([[V710], [BIG], [V710]]); + const p = await person('free-limits'); + + const big = await post(p.headers, { on: 'fleet', model: BIG.model }); + expect(big.status).toBe(403); + const refusal = await json(big); + expect(refusal.code).toBe('gpu_needs_pro'); + expect(refusal.message).toBe(`Upgrade to create a room on ${BIG.model}`); + + expect((await post(p.headers, { on: 'fleet', model: V710.model })).status).toBe(201); + const second = await post(p.headers, { on: 'fleet', model: V710.model }); + expect(second.status).toBe(403); + const limit = await json(second); + expect(limit.code).toBe('room_limit'); + expect(limit.message).toBe(`Upgrade to create a room on ${V710.model}`); + + // Your own GPU is still open at the limit. + const host = await Fixtures.machine(p.owner, 'free-own'); + await Machine.setGpus({ id: host, gpus: [BIG] }); + expect((await post(p.headers, { on: 'own', machineId: host, pciId: BIG.pciId })).status).toBe( + 201 + ); + }); + + test('pro: three cloud rooms on any model, then room_limit', async () => { + await fleet([[BIG], [BIG], [BIG], [BIG]]); + const p = await person('pro-limits', 'paid'); + for (let i = 0; i < 3; i++) { + const res = await post(p.headers, { on: 'fleet', model: BIG.model }); + expect(res.status).toBe(201); + } + const over = await post(p.headers, { on: 'fleet', model: BIG.model }); + expect(over.status).toBe(403); + expect((await json(over)).code).toBe('room_limit'); + + const billing = await json(await app.request('/billing', { headers: p.headers })); + expect(billing.data.plan).toBe('paid'); + expect(billing.data.rooms).toEqual({ used: 3, allowed: 3 }); + }); + + test('the entry list is configuration', async () => { + const f = await fleet([[BIG]]); + useFleet(f.organisationId, 'h100'); + const p = await person('entry-config'); + expect((await post(p.headers, { on: 'fleet', model: BIG.model })).status).toBe(201); + }); +}); + +describe('GET /billing', () => { + test('answers plan and rooms, with no windows', async () => { + await fleet([[V710]]); + const p = await person('billing-shape'); + const { data } = await json(await app.request('/billing', { headers: p.headers })); + expect(Object.keys(data).sort()).toEqual(['plan', 'rooms', 'teamId']); + expect(data.rooms).toEqual({ used: 0, allowed: 1 }); + }); +}); + +describe('the idle limit', () => { + test('a cloud room with nothing live for 3 hours is stopped, and its record stays', async () => { + const f = await fleet([[V710]]); + const p = await person('idle'); + const box = (await json(await post(p.headers, { on: 'fleet', model: V710.model }))).data; + await Box.setState({ id: box.id, state: 'running' }); + + // Fresh: left alone. + expect(await Box.stopIdle(f.machines[0]!.id)).toEqual([]); + + await sql`update box set time_created = now() - interval '4 hours' where id = ${box.id}`; + expect(await Box.stopIdle(f.machines[0]!.id)).toEqual([box.id]); + const after = (await Box.fromID(box.id))!; + expect(after.state).toBe('stopped'); + expect(after.stopReason).toBe(Box.IDLE_REASON); + }); + + test('your own hardware has no idle limit', async () => { + const p = await person('idle-own'); + const host = await Fixtures.machine(p.owner, 'idle-own-host'); + const box = (await json(await post(p.headers, { label: 'x' }))).data; + await Box.setState({ id: box.id, state: 'running' }); + await sql`update box set time_created = now() - interval '9 hours' where id = ${box.id}`; + expect(await Box.stopIdle(host)).toEqual([]); + expect((await Box.fromID(box.id))!.state).toBe('running'); + }); +}); diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index f9fdc1db..23fa50ed 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -142,16 +142,9 @@ describe('POST /session', () => { // start a run has to show what it costs and what remains, and a second // call for that is a call nobody makes. expect(Object.keys(body)).toEqual(['data', 'billing']); - expect(body.billing.exhausted).toBe(false); - expect(body.billing.windows.map((w: { window: string }) => w.window)).toEqual([ - 'fiveHour', - 'sevenDay', - 'thirtyDay' - ]); - // Nothing is live yet, so nothing is being spent — and one more run - // would cost exactly one unit per second. - expect(body.billing.rateMilli).toBe(0); - expect(body.billing.rateMilliIfOneMore).toBe(1000); + // Plans count rooms; there are no time windows to report. + expect(Object.keys(body.billing).sort()).toEqual(['plan', 'rooms', 'teamId']); + expect(body.billing.rooms.allowed).toBe(1); expect(body.data).toEqual({ id: body.data.id, boxId: s.box.id, diff --git a/apps/api/test/trial.test.ts b/apps/api/test/trial.test.ts deleted file mode 100644 index 4f25fc1d..00000000 --- a/apps/api/test/trial.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from 'bun:test'; - -import { AccessToken } from '@nestri/core/access-token/index'; -import { Fixtures } from '@nestri/core/db/fixtures'; -import { testDb } from '@nestri/core/db/test'; -import { Env } from '@nestri/core/env'; -import { Game } from '@nestri/core/game/index'; -import { Identifier } from '@nestri/core/id'; -import { Trial } from '@nestri/core/trial/index'; -import { Library } from '@nestri/core/user/library'; - -import { app } from '../app/index'; -import { TEST_FRONTEND_URL } from './setup'; - -const sql = testDb(); -const createdUserIds: string[] = []; -const createdOrgIds: string[] = []; -const createdGameIds: string[] = []; - -/** Times on the clock the trial keeps, written out as instants. */ -const WEDNESDAY = new Date('2026-10-07T12:00:00Z'); -const SATURDAY = new Date('2026-10-10T12:00:00Z'); - -function at(instant: Date) { - Trial.useClock(() => instant); -} - -afterEach(() => Trial.useClock(() => new Date())); - -afterAll(async () => { - await sql`delete from trial_claim where user_id = any(${createdUserIds}) or email like '%@trial.example.test'`; - await sql`delete from burn_segment where session_id in (select s.id from session s join box b on b.id = s.box_id where b.user_id = any(${createdUserIds}))`; - await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`; - await sql`delete from box where user_id = any(${createdUserIds})`; - await sql`delete from machine where owner_user_id = any(${createdUserIds})`; - await sql`delete from organisation where id = any(${createdOrgIds})`; - await sql`delete from game where id = any(${createdGameIds})`; - await sql`delete from "user" where id = any(${createdUserIds})`; -}); - -/** A fleet of one, a person with a box on it and a game to play, and both credentials. */ -async function scene(label: string) { - const operator = await Fixtures.owner(`${label}-op`); - createdUserIds.push(operator.userId); - const fleet = await Fixtures.fleet(operator, 1); - createdOrgIds.push(fleet.organisationId); - Env.init({ - NODE_ENV: 'test', - FRONTEND_URL: TEST_FRONTEND_URL, - FLEET_ORGANISATION_ID: fleet.organisationId - }); - - const owner = await Fixtures.owner(label); - createdUserIds.push(owner.userId); - const pat = await AccessToken.create({ - id: Identifier.ascending('accessToken'), - ownerUserId: owner.userId, - teamId: null, - name: label - }); - const user = { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }; - - const boxRes = await app.request('/box', { - method: 'POST', - headers: user, - body: JSON.stringify({ on: 'fleet' }) - }); - const box = ((await boxRes.json()) as any).data; - - const steamAppId = 980000 + createdGameIds.length; - const [game] = await Game.upsert({ - id: Identifier.ascending('game'), - steamAppId, - slug: `trial-${steamAppId}`, - name: `Trial ${steamAppId}` - }); - createdGameIds.push(game!.id); - await Library.upsert({ - id: Identifier.ascending('userLibrary'), - userId: owner.userId, - gameId: game!.id, - playtime2w: null, - playtimeForever: null, - lastPlayed: null - }); - - const machine = fleet.machines[0]!; - return { - owner, - box, - gameId: game!.id, - user, - host: { - 'x-nestri-machine-id': machine.id, - 'x-nestri-machine-secret': machine.secret - } - }; -} - -async function play(s: Awaited>) { - const res = await app.request('/session', { - method: 'POST', - headers: s.user, - body: JSON.stringify({ - boxId: s.box.id, - gameId: s.gameId, - linkedAccountId: s.owner.linkedAccountId - }) - }); - return { res, body: (await res.json()) as any }; -} - -/** A past run of this box, live from `start` for `minutes`, as the trial counts it. */ -async function playedBefore( - boxId: string, - gameId: string, - linkedAccountId: string, - start: Date, - minutes: number -) { - const id = Identifier.ascending('session'); - const stop = new Date(start.getTime() + minutes * 60_000); - await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started, time_stopped) - values (${id}, ${boxId}, ${gameId}, ${linkedAccountId}, 'ended', true, ${start}, ${stop})`; - return id; -} - -describe('The weekend window', () => { - test('Saturday 00:00 to Tuesday 00:00 on the trial’s clock', () => { - const closed = Trial.window(WEDNESDAY); - expect(closed.open).toBe(false); - // Summer time: midnight there is 21:00 UTC the day before. - expect(closed.start.toISOString()).toBe('2026-10-09T21:00:00.000Z'); - expect(closed.end.toISOString()).toBe('2026-10-12T21:00:00.000Z'); - - const open = Trial.window(SATURDAY); - expect(open.open).toBe(true); - expect(open.start.toISOString()).toBe(closed.start.toISOString()); - }); - - test('a weekend with a clock change in it is still three calendar days', () => { - // Summer time ends in the early hours of Sunday 25 October 2026. - const w = Trial.window(new Date('2026-10-24T12:00:00Z')); - expect(w.start.toISOString()).toBe('2026-10-23T21:00:00.000Z'); - expect(w.end.toISOString()).toBe('2026-10-26T22:00:00.000Z'); - }); - - test('Monday is in; Friday is not', () => { - expect(Trial.window(new Date('2026-10-12T12:00:00Z')).open).toBe(true); - expect(Trial.window(new Date('2026-10-09T12:00:00Z')).open).toBe(false); - }); - - test('the last minute of Monday is in; Tuesday 00:00 is out', () => { - expect(Trial.window(new Date('2026-10-12T20:59:00Z')).open).toBe(true); - expect(Trial.window(new Date('2026-10-12T21:00:00Z')).open).toBe(false); - }); -}); - -describe('POST /session on rented GPUs without a plan', () => { - test('in the window: a trial run, and the claim is made', async () => { - at(SATURDAY); - const s = await scene('trial-ok'); - const { res, body } = await play(s); - expect(res.status).toBe(201); - expect(body.data.trial).toBe(true); - expect(body.billing).toBeNull(); - const claims = - await sql`select email, steam_id from trial_claim where team_id = ${s.owner.teamId}`; - expect(claims.length).toBe(1); - }); - - test('outside the window: 429, naming when it opens', async () => { - at(WEDNESDAY); - const s = await scene('trial-closed'); - const { res, body } = await play(s); - expect(res.status).toBe(429); - expect(body.message).toContain('Saturday to Monday'); - }); - - test('two hours used this weekend: 429', async () => { - at(SATURDAY); - const s = await scene('trial-spent'); - await playedBefore( - s.box.id, - s.gameId, - s.owner.linkedAccountId, - new Date('2026-10-10T08:00:00Z'), - 120 - ); - const { res, body } = await play(s); - expect(res.status).toBe(429); - expect(body.message).toContain('2 free hours'); - }); - - test('time from last weekend does not count', async () => { - at(SATURDAY); - const s = await scene('trial-last-week'); - await playedBefore( - s.box.id, - s.gameId, - s.owner.linkedAccountId, - new Date('2026-10-03T10:00:00Z'), - 180 - ); - expect((await play(s)).res.status).toBe(201); - }); - - test('an address that already had the trial: 403', async () => { - at(SATURDAY); - const s = await scene('trial-email'); - const [person] = await sql`select email from "user" where id = ${s.owner.userId}`; - await sql`insert into trial_claim (id, email, steam_id) - values (${Identifier.ascending('trialClaim')}, ${person!.email}, ${'old-' + s.owner.userId})`; - const { res, body } = await play(s); - expect(res.status).toBe(403); - expect(body.message).toContain('already had the free weekend'); - }); - - test('a Steam account that already had the trial: 403', async () => { - at(SATURDAY); - const s = await scene('trial-steam'); - const [linked] = - await sql`select provider_account_id from linked_account where id = ${s.owner.linkedAccountId}`; - await sql`insert into trial_claim (id, email, steam_id) - values (${Identifier.ascending('trialClaim')}, ${'gone@trial.example.test'}, ${linked!.provider_account_id})`; - expect((await play(s)).res.status).toBe(403); - }); - - test('five paying teams close it for everyone', async () => { - at(SATURDAY); - const s = await scene('trial-full'); - const payers: string[] = []; - for (let i = 0; i < Trial.PAID_SEATS; i++) { - const p = await Fixtures.owner(`payer-${i}`); - createdUserIds.push(p.userId); - payers.push(p.teamId); - } - await sql`update team set plan = 'paid' where id = any(${payers})`; - try { - const { res, body } = await play(s); - expect(res.status).toBe(403); - expect(body.message).toContain('has closed'); - } finally { - await sql`update team set plan = 'free' where id = any(${payers})`; - } - }); - - test('a team with a plan is not on the trial', async () => { - at(WEDNESDAY); - const s = await scene('trial-paid'); - await sql`update team set plan = 'paid' where id = ${s.owner.teamId}`; - try { - const { res, body } = await play(s); - expect(res.status).toBe(201); - expect(body.data.trial).toBe(false); - expect(body.billing).not.toBeNull(); - } finally { - await sql`update team set plan = 'free' where id = ${s.owner.teamId}`; - } - }); -}); - -describe('Stopping a trial run', () => { - async function liveTrialRun(s: Awaited>, startedAt: Date) { - const id = Identifier.ascending('session'); - await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started) - values (${id}, ${s.box.id}, ${s.gameId}, ${s.owner.linkedAccountId}, 'live', true, ${startedAt})`; - return id; - } - - async function jobs(s: Awaited>) { - const res = await app.request('/machine/jobs', { headers: s.host }); - return ((await res.json()) as any).data as any[]; - } - - test('nothing to stop while the hours last', async () => { - at(SATURDAY); - const s = await scene('stop-none'); - await liveTrialRun(s, new Date(SATURDAY.getTime() - 60 * 60_000)); - expect((await jobs(s)).filter((j) => j.kind === 'session.stop')).toEqual([]); - }); - - test('past two hours: the host is told to stop it, for hours', async () => { - at(SATURDAY); - const s = await scene('stop-hours'); - const id = await liveTrialRun(s, new Date(SATURDAY.getTime() - 121 * 60_000)); - const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop'); - expect(stops).toEqual([ - { kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'hours' } - ]); - }); - - test('when the window closes: stopped, for the window', async () => { - at(new Date('2026-10-12T21:05:00Z')); - const s = await scene('stop-window'); - const id = await liveTrialRun(s, new Date('2026-10-12T20:30:00Z')); - const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop'); - expect(stops).toEqual([ - { kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'window' } - ]); - }); -}); diff --git a/packages/core/migrations/0029_box_gpu_pci_id.sql b/packages/core/migrations/0029_box_gpu_pci_id.sql new file mode 100644 index 00000000..d0a7158f --- /dev/null +++ b/packages/core/migrations/0029_box_gpu_pci_id.sql @@ -0,0 +1 @@ +ALTER TABLE "box" ADD COLUMN "gpu_pci_id" text; \ No newline at end of file diff --git a/packages/core/migrations/meta/0029_snapshot.json b/packages/core/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..8f401f3f --- /dev/null +++ b/packages/core/migrations/meta/0029_snapshot.json @@ -0,0 +1,4053 @@ +{ + "id": "b0f0a475-ecd4-4ac9-8fff-de9d63e4136b", + "prevId": "ba57e3c9-8eb2-4d7a-9c75-6b0166bee6c9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gpu_pci_id": { + "name": "gpu_pci_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.launch": { + "name": "launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "launch_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "first": { + "name": "first", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "launch_session_idx": { + "name": "launch_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_state_idx": { + "name": "launch_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "launch_session_id_session_id_fk": { + "name": "launch_session_id_session_id_fk", + "tableFrom": "launch", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "launch_game_id_game_id_fk": { + "name": "launch_game_id_game_id_fk", + "tableFrom": "launch", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_claimed": { + "name": "time_claimed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.launch_state": { + "name": "launch_state", + "schema": "public", + "values": [ + "requested", + "running", + "ended", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 57e3c81e..94a82599 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1792231939946, "tag": "0028_machine_capabilities", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1792436256695, + "tag": "0029_box_gpu_pci_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/billing/index.ts b/packages/core/src/billing/index.ts index f7858a3c..755e341a 100644 --- a/packages/core/src/billing/index.ts +++ b/packages/core/src/billing/index.ts @@ -1,44 +1,30 @@ import z from 'zod'; import { Box } from '../box/index.js'; -import { ErrorCodes, VisibleError } from '../error.js'; +import { Env } from '../env.js'; import { fn } from '../fn.js'; import { Machine } from '../machine/index.js'; import { Team } from '../team/index.js'; import { Burn } from './burn.js'; -import { Limits } from './limits.js'; -import { Window } from './window.js'; +import { Rooms } from './rooms.js'; /** - * Whether a run may start, and what it will cost to have started it. + * What a team is on, and who pays for a box. * - * The one rule this must never break: **a limit refuses the next run, it never - * stops one already going.** Someone losing a session mid-game to a meter does - * not come back, and no amount of correct arithmetic makes that a good trade. - * So everything here is asked before a run begins and never again. + * Plans count rooms and nothing else. The weekend trial and the rolling usage + * windows (five hours, seven days, thirty days) used to decide here whether a + * run could start; they no longer do, and nothing refuses a run for time spent. + * `Limits` and `Window` are still in this directory, unused, and burn is still + * recorded per run. */ export namespace Billing { - export const WindowState = Window.State.extend({ - window: z.enum(['fiveHour', 'sevenDay', 'thirtyDay']), - label: z.string() - }); - export const State = z.object({ teamId: z.string(), plan: z.string(), - /** True when any window is spent. */ - exhausted: z.boolean(), - /** - * What the account is spending per second right now, times a thousand, - * and what it would spend with one more run. - * - * Both, because the rule is that a cost is shown *before* it is - * incurred: a person about to start a third session needs to be told - * what that does to the rate while they can still not do it. - */ - rateMilli: z.number().int(), - rateMilliIfOneMore: z.number().int(), - windows: z.array(WindowState) + rooms: z.object({ + used: z.number().int().meta({ description: 'Cloud rooms you hold on Nestri GPUs' }), + allowed: z.number().int().meta({ description: 'Cloud rooms your plan allows' }) + }) }); export type State = z.infer; @@ -74,88 +60,23 @@ export namespace Billing { }; }); - /** Where a team stands, in every window, with the rates to show beside it. */ + /** How many cloud rooms a person holds: their boxes on fleet machines. */ + export async function cloudRooms(userId: string): Promise { + const organisationId = Env.get().FLEET_ORGANISATION_ID; + if (!organisationId) return 0; + return (await Box.onFleet({ userId, organisationId })).length; + } + + /** The plan, and cloud rooms used against allowed, for one team and person. */ export const state = fn( - z.object({ - teamId: z.string(), - /** The run being considered, so "one more" can be costed honestly. */ - nextTier: Burn.Tier.optional(), - nextHostClass: Burn.HostClass.optional() - }), + z.object({ teamId: z.string(), userId: z.string() }), async (input): Promise => { - const teamId = input.teamId; - const team = await Team.fromID(teamId); - const plan = team?.plan ?? 'free'; - const allowances = Limits.forPlan(plan); - const counters = await Burn.counters(teamId); - const open = await Burn.openSegments(teamId); - - const windows = Window.ALL.map((window) => { - const usage = counters ? Number(counters[`${window.key}Usage`] ?? 0) : 0; - const at = counters ? (counters[`${window.key}At`] ?? null) : null; - return { - window: window.key, - label: window.label, - ...Window.analyze({ - allowance: allowances[window.key], - windowSeconds: window.seconds, - usage, - timeUpdated: at - }) - }; - }); - - // The account's total is the sum of what each run costs, not a count - // times one rate: an `xl` run and an `xs` one alongside it are not - // two of anything. Concurrency shows up here, as there being more to - // add, rather than as a multiplier on any of them. - const rateMilli = open.reduce((total, segment) => total + segment.rateMilli, 0); - const next = Burn.baseRateMilli({ - tier: input.nextTier ?? 'md', - hostClass: input.nextHostClass ?? 'byo' - }); - + const plan = (await Team.fromID(input.teamId))?.plan ?? 'free'; return { - teamId, + teamId: input.teamId, plan, - exhausted: windows.some((w) => w.exhausted), - rateMilli, - rateMilliIfOneMore: rateMilli + next, - windows + rooms: { used: await Billing.cloudRooms(input.userId), allowed: Rooms.allowed(plan) } }; } ); - - /** - * Refuse a new run when any window is spent. - * - * Every window is checked, not the shortest: they protect different things - * over different spans, and a set where only one could ever fire is a set - * with two decorative numbers in it. - * - * The refusal names the window and when it clears, because a limit a person - * cannot plan around is the one they resent. `QUOTA_EXCEEDED` maps to 429, - * which is the honest status — this is a rate limit the customer experiences - * as a budget, and it will succeed later without anything changing. - */ - export const assertMayStart = fn( - z.object({ - teamId: z.string(), - nextTier: Burn.Tier.optional(), - nextHostClass: Burn.HostClass.optional() - }), - async (input) => { - const current = await state(input); - const spent = current.windows.find((w) => w.exhausted); - if (!spent) { - return current; - } - const minutes = Math.ceil(spent.resetInSec / 60); - throw new VisibleError( - 'rate_limit', - ErrorCodes.RateLimit.QUOTA_EXCEEDED, - `Your ${spent.label} allowance is spent. It clears in about ${minutes} minute${minutes === 1 ? '' : 's'}. Runs already going are not affected.` - ); - } - ); } diff --git a/packages/core/src/billing/rooms.ts b/packages/core/src/billing/rooms.ts new file mode 100644 index 00000000..07626ba6 --- /dev/null +++ b/packages/core/src/billing/rooms.ts @@ -0,0 +1,67 @@ +import { Env } from '../env.js'; +import { ErrorCodes, VisibleError } from '../error.js'; + +/** + * What a plan allows: rooms, and nothing measured in time. + * + * A room is a box pinned to one GPU. Cloud rooms (on the fleet's GPUs) are + * counted against the plan; rooms on a person's own GPUs are never limited. + */ +export namespace Rooms { + /** Cloud rooms per plan. `paid` is the Pro plan; anything else is free. */ + export const LIMITS = { free: 1, paid: 3 } as const; + + /** + * Fleet GPU models a free plan may use, as case-insensitive substrings of the + * model name. Overridden by `FLEET_ENTRY_GPUS`, which is what a deploy sets + * as the fleet changes. + */ + export const DEFAULT_ENTRY_GPUS = ['Radeon Pro V710', 'RTX A2000', 'RTX 3060']; + + export function entryModels(): string[] { + const raw = Env.get().FLEET_ENTRY_GPUS; + if (raw === undefined) return DEFAULT_ENTRY_GPUS; + return raw + .split(',') + .map((m) => m.trim()) + .filter(Boolean); + } + + export function isEntry(model: string): boolean { + const name = model.toLowerCase(); + return entryModels().some((m) => name.includes(m.toLowerCase())); + } + + export function isPro(plan: string | null | undefined): boolean { + return plan === 'paid'; + } + + export function allowed(plan: string | null | undefined): number { + return isPro(plan) ? LIMITS.paid : LIMITS.free; + } + + /** Whether a plan may put a room on this fleet model. */ + export function mayUseModel(plan: string | null | undefined, model: string): boolean { + return isPro(plan) || isEntry(model); + } + + export function needsPro(model: string): VisibleError { + return new VisibleError( + 'forbidden', + ErrorCodes.Permission.GPU_NEEDS_PRO, + `Upgrade to create a room on ${model}`, + undefined, + { model } + ); + } + + export function atLimit(model: string | null, plan: string | null | undefined): VisibleError { + return new VisibleError( + 'forbidden', + ErrorCodes.Permission.ROOM_LIMIT, + `Upgrade to create a room on ${model ?? 'a Nestri GPU'}`, + undefined, + { model, allowed: allowed(plan) } + ); + } +} diff --git a/packages/core/src/box/box.sql.ts b/packages/core/src/box/box.sql.ts index 92742445..dbdad944 100644 --- a/packages/core/src/box/box.sql.ts +++ b/packages/core/src/box/box.sql.ts @@ -58,6 +58,11 @@ export const BoxTable = pgTable( // it is deliberately not unique: two boxes called "living room" are the // owner's problem, not an error. ref(d-0019) label: text('label').notNull(), + /** + * The GPU on that host this box is pinned to, as the host reported it + * (`vendor:device`). Null on boxes made before a box named its card. + */ + gpuPciId: text('gpu_pci_id'), tier: BoxTier('tier').notNull().default('md'), state: BoxState('state').notNull().default('created'), /** Why it stopped, verbatim from the agent. Null while it has never run. */ diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index 6d894572..84e4f7c1 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull, notInArray, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, isNull, notInArray, sql } from 'drizzle-orm'; import z from 'zod'; import { Database } from '../db/index.js'; @@ -6,6 +6,7 @@ import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { Machine } from '../machine/index.js'; import { MachineTable } from '../machine/machine.sql.js'; +import { SessionTable } from '../session/session.sql.js'; import { BoxState, BoxTable, BoxTier } from './box.sql.js'; import { Placement } from './placement.js'; @@ -32,6 +33,11 @@ export namespace Box { description: 'The host it is placed on', example: Examples.Box.machineId }), + gpuPciId: z.string().nullable().optional().meta({ + description: + 'The GPU on its host the box is pinned to (`vendor:device`). Null for a box made before boxes named a card', + example: null + }), label: z.string().meta({ description: 'Editable display name. Not unique, and not the DNS label', example: Examples.Box.label @@ -76,7 +82,14 @@ export namespace Box { export type Info = z.infer; export const create = fn( - Info.pick({ id: true, userId: true, machineId: true, label: true, tier: true }), + Info.pick({ + id: true, + userId: true, + machineId: true, + gpuPciId: true, + label: true, + tier: true + }), async (input) => { return Database.use(async (tx) => { return tx @@ -85,6 +98,7 @@ export namespace Box { id: input.id, userId: input.userId, machineId: input.machineId, + gpuPciId: input.gpuPciId ?? null, label: input.label, tier: input.tier }) @@ -107,11 +121,12 @@ export namespace Box { input: { id: string; userId: string; label: string; tier: Info['tier'] }, placer?: Placement.Placer ) => { - const machineId = await Placement.choose({ userId: input.userId, tier: input.tier }, placer); + const placed = await Placement.choose({ userId: input.userId, tier: input.tier }, placer); return create({ id: input.id, userId: input.userId, - machineId, + machineId: placed.machineId, + gpuPciId: placed.gpuPciId, label: input.label, tier: input.tier }); @@ -145,17 +160,18 @@ export namespace Box { .orderBy(BoxTable.timeCreated) .then((rows) => rows.map((r) => - serialize(r.box, Machine.gpuName(r.gpus), { host: r.host, fleet: r.organisationId !== null }) + serialize(r.box, Machine.gpuName(r.gpus), { + host: r.host, + fleet: r.organisationId !== null + }) ) ); }); }); /** - * The box a person already holds on the fleet's hardware, if any. - * - * One each: a rented GPU is the scarce thing, and a second box would be a - * second claim on it that nothing yet prices. + * The boxes a person holds on the fleet's hardware: their cloud rooms. + * What a plan allows is counted from this. */ export const onFleet = fn( z.object({ userId: Info.shape.userId, organisationId: z.string() }), @@ -173,7 +189,8 @@ export namespace Box { isNull(MachineTable.timeDeleted) ) ) - .then((rows) => (rows[0] ? serialize(rows[0].box) : null)); + .orderBy(BoxTable.timeCreated) + .then((rows) => rows.map((r) => serialize(r.box))); }); } ); @@ -218,6 +235,60 @@ export namespace Box { } ); + /** How long a cloud room may sit with no run before its GPU is released. */ + export const IDLE_LIMIT_SECONDS = 3 * 60 * 60; + + export const IDLE_REASON = 'idle for 3 hours'; + + /** + * Stop the cloud rooms on one host that have had nothing live for + * {@link IDLE_LIMIT_SECONDS}. The record stays; only `state` changes, which + * is what frees the GPU for placement. A person's own hardware is never + * touched: only machines an organisation owns are looked at. + * + * Idle is measured from the end of the room's last run, or from its creation + * when it never had one. A room with a run waiting, starting or live is + * never idle. + */ + export const stopIdle = fn(z.string(), async (machineId): Promise => { + return Database.use(async (tx) => { + const lastRun = tx + .select({ + boxId: SessionTable.boxId, + lastStopped: sql`max(${SessionTable.timeStopped})`.as('last_stopped') + }) + .from(SessionTable) + .groupBy(SessionTable.boxId) + .as('last_run'); + const candidates = await tx + .select({ id: BoxTable.id }) + .from(BoxTable) + .innerJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId)) + .leftJoin(lastRun, eq(lastRun.boxId, BoxTable.id)) + .where( + and( + eq(BoxTable.machineId, machineId), + eq(BoxTable.state, 'running'), + isNull(BoxTable.timeDeleted), + isNotNull(MachineTable.organisationId), + sql`coalesce(${lastRun.lastStopped}, ${BoxTable.timeCreated}) < now() - make_interval(secs => ${IDLE_LIMIT_SECONDS})`, + sql`not exists (select 1 from ${SessionTable} where ${SessionTable.boxId} = ${BoxTable.id} and ${SessionTable.state} in ('requested', 'starting', 'live') and ${SessionTable.timeDeleted} is null)` + ) + ); + if (candidates.length === 0) return []; + await tx + .update(BoxTable) + .set({ state: 'stopped', stopReason: IDLE_REASON, stopClean: true }) + .where( + inArray( + BoxTable.id, + candidates.map((c) => c.id) + ) + ); + return candidates.map((c) => c.id); + }); + }); + export const rename = fn(Info.pick({ id: true, userId: true, label: true }), async (input) => { return Database.use(async (tx) => { return tx @@ -386,7 +457,11 @@ export namespace Box { // holding no boxes — is spelt out instead of relying on that. const missing = await tx .update(BoxTable) - .set({ state: 'stopped', stopReason: OMITTED_FROM_REPORT, stopClean: false }) + .set({ + state: 'stopped', + stopReason: OMITTED_FROM_REPORT, + stopClean: false + }) .where( and( eq(BoxTable.machineId, input.machineId), @@ -412,6 +487,7 @@ export namespace Box { id: input.id, userId: input.userId, machineId: input.machineId, + gpuPciId: input.gpuPciId, label: input.label, tier: input.tier as Info['tier'], state: input.state as Info['state'], diff --git a/packages/core/src/box/location.ts b/packages/core/src/box/location.ts new file mode 100644 index 00000000..85d12fdf --- /dev/null +++ b/packages/core/src/box/location.ts @@ -0,0 +1,77 @@ +import z from 'zod'; + +import { Rooms } from '../billing/rooms.js'; +import { Env } from '../env.js'; +import { fn } from '../fn.js'; +import { Machine } from '../machine/index.js'; +import { Placement } from './placement.js'; + +/** + * Where a room can go: one GPU each. A person's own are listed one by one; + * the fleet's are grouped by model, because which machine serves a model is + * placement's choice and not theirs. + */ +export namespace Location { + export const Own = z + .object({ + machineId: z.string().meta({ description: 'The host the card is in' }), + pciId: z.string().meta({ description: 'vendor:device, as the host reported it' }), + model: z.string().meta({ description: 'What the card is called' }), + vramMib: z.number().int().nullable().meta({ description: 'Card memory in MiB, if reported' }), + online: z.boolean().meta({ description: 'Whether its host is reporting in' }) + }) + .meta({ ref: 'OwnLocation' }); + + export const Fleet = z + .object({ + model: z.string().meta({ description: 'The card, as a person recognises it' }), + vramMib: z.number().int().nullable().meta({ description: 'Card memory in MiB, if reported' }), + available: z.boolean().meta({ description: 'Whether one is free to put a room on now' }), + entry: z.boolean().meta({ + description: 'Whether this is an entry model, open to every plan' + }), + allowed: z.boolean().meta({ description: 'Whether your plan may create a room on it' }) + }) + .meta({ ref: 'FleetLocation' }); + + export const Info = z.object({ own: z.array(Own), fleet: z.array(Fleet) }).meta({ + ref: 'Locations', + description: 'Every GPU a room could be created on' + }); + + export type Info = z.infer; + + export const list = fn( + z.object({ userId: z.string(), plan: z.string().nullable().optional() }), + async (input): Promise => { + const own = (await Machine.listByOwner(input.userId)).flatMap((m) => + (m.gpus ?? []).map((g) => ({ + machineId: m.id, + pciId: g.pciId, + model: g.model, + vramMib: g.vramMib ?? null, + online: Machine.isOnline(m.lastSeen ?? null) + })) + ); + + const organisationId = Env.get().FLEET_ORGANISATION_ID; + const byModel = new Map(); + if (organisationId) { + for (const card of await Placement.fleetCards(organisationId)) { + const seen = byModel.get(card.model); + byModel.set(card.model, { + model: card.model, + vramMib: Math.max(seen?.vramMib ?? 0, card.vramMib ?? 0) || null, + available: (seen?.available ?? false) || card.free, + entry: Rooms.isEntry(card.model), + allowed: Rooms.mayUseModel(input.plan, card.model) + }); + } + } + return { + own, + fleet: [...byModel.values()].sort((a, b) => a.model.localeCompare(b.model)) + }; + } + ); +} diff --git a/packages/core/src/box/placement.test.ts b/packages/core/src/box/placement.test.ts index e24e9379..7cc4ee63 100644 --- a/packages/core/src/box/placement.test.ts +++ b/packages/core/src/box/placement.test.ts @@ -74,7 +74,7 @@ describe('Placement', () => { }, async (input) => { asked.push(input); - return machineId; + return { machineId, gpuPciId: null }; } ); diff --git a/packages/core/src/box/placement.ts b/packages/core/src/box/placement.ts index 98e27791..0af1fbf3 100644 --- a/packages/core/src/box/placement.ts +++ b/packages/core/src/box/placement.ts @@ -1,4 +1,4 @@ -import { and, count, inArray, isNull } from 'drizzle-orm'; +import { and, count, eq, inArray, isNotNull, isNull, ne } from 'drizzle-orm'; import z from 'zod'; import { Database } from '../db/index.js'; @@ -36,7 +36,13 @@ export namespace Placement { * placer that cannot honour a request must say so rather than return * something the insert would reject — `box.machineId` is not nullable. */ - export type Placer = (request: Request) => Promise; + export type Placer = (request: Request) => Promise; + + /** Where a box goes: a host, and the card on it when one was chosen. */ + export interface Placed { + machineId: string; + gpuPciId: string | null; + } /** * The implementation there is hardware for: place it on the caller's host. @@ -67,7 +73,7 @@ export namespace Placement { ); } - return hosts[0]!.id; + return { machineId: hosts[0]!.id, gpuPciId: null }; }; /** @@ -138,11 +144,138 @@ export namespace Placement { const slots = new Map(capacity.map((c) => [c.id, Math.max(1, c.slots ?? 1)])); const load = (id: string) => (boxes.get(id) ?? 0) / (slots.get(id) ?? 1); // Stable: on a tie, the order `listByOrganisation` gives, oldest first. - return [...online].sort((a, b) => load(a.id) - load(b.id))[0]!.id; + return { + machineId: [...online].sort((a, b) => load(a.id) - load(b.id))[0]!.id, + gpuPciId: null + }; }; + /** + * Place it on a GPU of the caller's own: one of the cards a host of theirs + * reported. Refused when the host is not theirs or does not have that card, + * with the same answer for both so a stranger's host id learns nothing. + */ + export const onOwn = + (target: { machineId: string; pciId: string }): Placer => + async (request) => { + const host = (await Machine.listByOwner(request.userId)).find( + (m) => m.id === target.machineId + ); + if (!host || !host.gpus?.some((g) => g.pciId === target.pciId)) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such GPU on a host of yours' + ); + } + return { machineId: host.id, gpuPciId: target.pciId }; + }; + + /** + * The fleet's GPUs, one entry per card, with whether each is free: online + * host, and no room on it that is not stopped. Cards of one model on one + * host that share a `vendor:device` id are told apart only by count. + */ + export async function fleetCards(organisationId: string) { + const machines = await Machine.listByOrganisation(organisationId); + const pinned = await Database.use((tx) => + tx + .select({ + machineId: BoxTable.machineId, + pci: BoxTable.gpuPciId, + held: count() + }) + .from(BoxTable) + .where( + and( + inArray( + BoxTable.machineId, + machines.map((m) => m.id) + ), + isNull(BoxTable.timeDeleted), + ne(BoxTable.state, 'stopped'), + isNotNull(BoxTable.gpuPciId) + ) + ) + .groupBy(BoxTable.machineId, BoxTable.gpuPciId) + ); + const held = new Map(pinned.map((p) => [`${p.machineId}/${p.pci}`, p.held])); + const cards: { + machineId: string; + pciId: string; + model: string; + vramMib: number | null; + online: boolean; + free: boolean; + }[] = []; + for (const m of machines) { + const online = Machine.isOnline(m.lastSeen ?? null); + const taken = new Map(); + for (const g of m.gpus ?? []) { + const seen = taken.get(g.pciId) ?? 0; + taken.set(g.pciId, seen + 1); + cards.push({ + machineId: m.id, + pciId: g.pciId, + model: g.model, + vramMib: g.vramMib ?? null, + online, + // The n-th identical card is free when fewer than n rooms hold that id. + free: online && (held.get(`${m.id}/${g.pciId}`) ?? 0) < seen + 1 + }); + } + } + return cards; + } + + /** + * Place it on a fleet GPU of one model: of the hosts with a free card of + * that model, the one holding the fewest boxes for its slots. + */ + export const onFleetModel = + (model: string): Placer => + async () => { + const organisationId = Env.get().FLEET_ORGANISATION_ID; + if (!organisationId) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + 'Nestri GPUs are not available on this deployment' + ); + } + const free = (await fleetCards(organisationId)).filter((c) => c.model === model && c.free); + if (free.length === 0) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + `No ${model} is free right now. Try again in a few minutes` + ); + } + const ids = [...new Set(free.map((c) => c.machineId))]; + const counts = await Database.use((tx) => + tx + .select({ + id: MachineTable.id, + slots: MachineTable.slots, + boxes: count(BoxTable.id) + }) + .from(MachineTable) + .leftJoin( + BoxTable, + and(eq(BoxTable.machineId, MachineTable.id), isNull(BoxTable.timeDeleted)) + ) + .where(inArray(MachineTable.id, ids)) + .groupBy(MachineTable.id) + ); + const load = new Map(counts.map((c) => [c.id, c.boxes / Math.max(1, c.slots ?? 1)])); + const pick = [...free].sort( + (a, b) => (load.get(a.machineId) ?? 0) - (load.get(b.machineId) ?? 0) + )[0]!; + return { machineId: pick.machineId, gpuPciId: pick.pciId }; + }; + /** Place a box, using `onlyHost` unless a caller supplies its own placer. */ - export async function choose(request: Request, placer: Placer = onlyHost): Promise { + export async function choose(request: Request, placer: Placer = onlyHost): Promise { return placer(Request.parse(request)); } } diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 9063cfb8..16e3b322 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -70,6 +70,12 @@ export namespace Env { * never written into the repository. */ FLEET_ORGANISATION_ID: z.string().optional(), + /** + * Fleet GPU models a free plan may create a room on, comma separated. + * Each entry matches a model name as a case-insensitive substring. Unset + * takes the default in `Rooms.DEFAULT_ENTRY_GPUS`. + */ + FLEET_ENTRY_GPUS: z.string().optional(), /** * This API's public address, for URLs handed to a browser that must come * back here (Steam's sign-in). Unset, the request's own host over https. diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index ffdfbb55..be660ca8 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -61,7 +61,11 @@ export const ErrorCodes = { Permission: { FORBIDDEN: 'forbidden', INSUFFICIENT_PERMISSIONS: 'insufficient_permissions', - ACCOUNT_RESTRICTED: 'account_restricted' + ACCOUNT_RESTRICTED: 'account_restricted', + /** The plan's cloud room allowance is used up. */ + ROOM_LIMIT: 'room_limit', + /** The GPU asked for is beyond the plan's entry models. */ + GPU_NEEDS_PRO: 'gpu_needs_pro' }, NotFound: { diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 451c0a2d..c7f00bc4 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -12,7 +12,6 @@ import { fn } from '../fn.js'; import { GameTable } from '../game/game.sql.js'; import { Identifier } from '../id.js'; import { MachineTable } from '../machine/machine.sql.js'; -import { Trial } from '../trial/index.js'; import { Launch } from './launch.js'; import { LaunchTable } from './launch.sql.js'; import { SessionState, SessionTable } from './session.sql.js'; @@ -171,7 +170,13 @@ export namespace Session { export type Info = z.infer; export const create = fn( - Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true, tier: true }).extend({ + Info.pick({ + id: true, + boxId: true, + gameId: true, + linkedAccountId: true, + tier: true + }).extend({ trial: z.boolean().optional() }), async (input) => { @@ -227,7 +232,13 @@ export namespace Session { * answered is a timing detail. */ export const request = fn( - Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true, tier: true }).extend({ + Info.pick({ + id: true, + boxId: true, + gameId: true, + linkedAccountId: true, + tier: true + }).extend({ trial: z.boolean().optional() }), async (input) => { @@ -340,7 +351,9 @@ export namespace Session { ? (input.endReason ?? null) : null, ...(input.state === 'live' - ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } + ? { + timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` + } : {}), ...(input.state === 'ended' || input.state === 'failed' ? { @@ -537,16 +550,15 @@ export namespace Session { ) .orderBy(SessionTable.timeStopRequested) ); - // One stop per run: when the owner asked and the trial also ran out, - // the owner's reason is the one the run ended for. - const seen = new Set(asked.map((r) => r.sessionId)); - const stops: Job[] = [ - ...asked.map((run): Job => ({ kind: 'session.stop', ...run, reason: 'user' })), - ...(await Trial.overdue(machineId)) - .filter((run) => !seen.has(run.sessionId)) - .map((run): Job => ({ kind: 'session.stop', ...run })) - ]; + const stops: Job[] = asked.map((run): Job => ({ + kind: 'session.stop', + ...run, + reason: 'user' + })); await leaveAbandoned(machineId); + // Runs on this poll's host, so the idle limit is checked as often as a + // host asks for work: cloud rooms with nothing live for 3 hours stop. + await Box.stopIdle(machineId); const free = await freeSlots(machineId); const starts = await Database.use(async (tx) => { return ( @@ -576,20 +588,18 @@ export namespace Session { .orderBy(SessionTable.trial, SessionTable.timeCreated) .limit(free) .then((rows) => - rows.map( - (row): Job => ({ - kind: 'session.start', - sessionId: row.session.id, - boxId: row.box.id, - // The run's own size when it was given one. - boxTier: (row.session.tier ?? row.box.tier) as z.infer['boxTier'], - gameId: row.game.id, - steamAppId: row.game.steamAppId, - linkedAccountId: row.session.linkedAccountId, - userId: row.box.userId, - launchId: row.launchId - }) - ) + rows.map((row): Job => ({ + kind: 'session.start', + sessionId: row.session.id, + boxId: row.box.id, + // The run's own size when it was given one. + boxTier: (row.session.tier ?? row.box.tier) as z.infer['boxTier'], + gameId: row.game.id, + steamAppId: row.game.steamAppId, + linkedAccountId: row.session.linkedAccountId, + userId: row.box.userId, + launchId: row.launchId + })) ) ); }); @@ -625,7 +635,11 @@ export namespace Session { */ export async function holdJobsForMachine( machineId: string, - options: { waitMs: number; signal?: AbortSignal; seen?: ReadonlySet } + options: { + waitMs: number; + signal?: AbortSignal; + seen?: ReadonlySet; + } ): Promise { const deadline = Date.now() + options.waitMs; for (;;) { @@ -735,7 +749,10 @@ export namespace Session { .where(eq(BoxTable.id, mine.boxId)) .then((rows) => rows[0]!.machineId); const waiting = await tx - .select({ trial: SessionTable.trial, timeCreated: SessionTable.timeCreated }) + .select({ + trial: SessionTable.trial, + timeCreated: SessionTable.timeCreated + }) .from(SessionTable) .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) .where( @@ -898,7 +915,9 @@ export namespace Session { } const [marked] = await tx .update(SessionTable) - .set({ timeStopRequested: sql`coalesce(${SessionTable.timeStopRequested}, now())` }) + .set({ + timeStopRequested: sql`coalesce(${SessionTable.timeStopRequested}, now())` + }) .where(and(scope, inArray(SessionTable.state, ['starting', 'live']))) .returning(); if (marked) { @@ -967,7 +986,9 @@ export namespace Session { endReason: input.to === 'ended' || input.to === 'failed' ? (input.endReason ?? null) : null, ...(input.to === 'live' - ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } + ? { + timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` + } : {}), ...(input.to === 'ended' || input.to === 'failed' ? { @@ -1034,12 +1055,7 @@ export namespace Session { * - `moved` — it happened. */ export type TransitionOutcome = - | 'forbidden' - | 'unchanged' - | 'illegal' - | 'notHolder' - | 'lost' - | 'moved'; + 'forbidden' | 'unchanged' | 'illegal' | 'notHolder' | 'lost' | 'moved'; export interface TransitionResult { outcome: TransitionOutcome; @@ -1062,9 +1078,11 @@ export namespace Session { * distinction `stopClean` exists for: "it is not running" and "it faulted" * are different facts and the difference lives in the reason. */ - function boxStateFor( - run: Info - ): { state: 'running' | 'stopped'; stopReason: string | null; stopClean: boolean | null } | null { + function boxStateFor(run: Info): { + state: 'running' | 'stopped'; + stopReason: string | null; + stopClean: boolean | null; + } | null { switch (run.state) { case 'live': return { state: 'running', stopReason: null, stopClean: null }; @@ -1073,7 +1091,11 @@ export namespace Session { case 'failed': // The run's own reason, so a box explains its stop in the words // the agent used rather than in a second wording of one event. - return { state: 'stopped', stopReason: run.errorMessage ?? null, stopClean: false }; + return { + state: 'stopped', + stopReason: run.errorMessage ?? null, + stopClean: false + }; default: return null; } @@ -1371,7 +1393,11 @@ export namespace Session { * it reports the launch over. Null when the run or launch is not theirs. */ export const stopLaunch = fn( - z.object({ sessionId: z.string(), launchId: z.string(), userId: z.string() }), + z.object({ + sessionId: z.string(), + launchId: z.string(), + userId: z.string() + }), async (input): Promise => { const run = await forOwner({ id: input.sessionId, userId: input.userId }); if (!run) return null; diff --git a/packages/core/src/trial/index.ts b/packages/core/src/trial/index.ts deleted file mode 100644 index 644b4eec..00000000 --- a/packages/core/src/trial/index.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { and, count, eq, isNull, sql } from 'drizzle-orm'; -import z from 'zod'; - -import { BoxTable } from '../box/box.sql.js'; -import { Database } from '../db/index.js'; -import { ErrorCodes, VisibleError } from '../error.js'; -import { fn } from '../fn.js'; -import { Identifier } from '../id.js'; -import { MachineTable } from '../machine/machine.sql.js'; -import { SessionTable } from '../session/session.sql.js'; -import { TeamTable } from '../team/team.sql.js'; -import { LinkedAccountTable } from '../user/linked-account.sql.js'; -import { UserTable } from '../user/user.sql.js'; -import { TrialClaimTable } from './trial.sql.js'; - -/** - * The free weekend: play on rented GPUs without a plan, a little, at weekends. - * - * Three rules and nothing else: - * - * - **When.** Saturday 00:00 to Tuesday 00:00 — Saturday, Sunday and - * Monday, Monday in place of Friday since 2026-10-11 — on the clock of the - * place the hardware is run from. Outside that, a trial run is refused, and one still - * going when it closes is stopped. - * - **How much.** {@link HOURS} of live time per window, across every run. - * Unlike a plan's allowance, this one stops a run that reaches it: the - * trial is the hardware lent, not a budget being drawn down. - * - **For how long.** Until {@link PAID_SEATS} teams pay for a plan. Then the - * hardware is theirs and the trial closes for everyone. - * - * Downloading a game is not playing it and is never refused here; only a run - * is. - */ -export namespace Trial { - export const HOURS = 2; - export const PAID_SEATS = 5; - export const ZONE = 'Europe/Helsinki'; - - const SECONDS = HOURS * 3600; - - /** - * What time it is, for every rule here. Replaceable so a test can stand on - * a Saturday; nothing else has a reason to touch it. - */ - export let now = () => new Date(); - export function useClock(clock: () => Date) { - now = clock; - } - - /** A zone's UTC offset at one instant, in milliseconds. */ - function offsetMs(at: Date): number { - const name = new Intl.DateTimeFormat('en-US', { timeZone: ZONE, timeZoneName: 'longOffset' }) - .formatToParts(at) - .find((p) => p.type === 'timeZoneName')!.value; - const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name); - if (!m) return 0; - const sign = m[1] === '-' ? -1 : 1; - return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000; - } - - /** Midnight at the start of a calendar day in {@link ZONE}, as an instant. */ - function midnight(year: number, month: number, day: number): Date { - const guess = Date.UTC(year, month - 1, day); - // Twice, because the offset at the guess and at the answer can differ by - // the hour a clock change moved. - const once = guess - offsetMs(new Date(guess)); - return new Date(guess - offsetMs(new Date(once))); - } - - /** The calendar day an instant falls on in {@link ZONE}, and its weekday (Mon 1 … Sun 7). */ - function localDay(at: Date) { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-US', { - timeZone: ZONE, - year: 'numeric', - month: 'numeric', - day: 'numeric', - weekday: 'short' - }) - .formatToParts(at) - .map((p) => [p.type, p.value]) - ); - const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(parts.weekday!) + 1; - return { - year: Number(parts.year), - month: Number(parts.month), - day: Number(parts.day), - weekday - }; - } - - function addDays(d: { year: number; month: number; day: number }, n: number) { - const t = new Date(Date.UTC(d.year, d.month - 1, d.day + n)); - return { year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate() }; - } - - export interface Window { - /** Whether `at` is inside it. */ - open: boolean; - /** The window `at` is in, or the next one. */ - start: Date; - end: Date; - } - - /** The weekend window an instant is in, or the next one if it is in none. */ - export function window(at: Date = now()): Window { - const today = localDay(at); - const sinceSaturday = (today.weekday - 6 + 7) % 7; // Sat 0, Sun 1, Mon 2, Tue 3 … - const open = sinceSaturday <= 2; - const saturday = addDays(today, open ? -sinceSaturday : 7 - sinceSaturday); - const tuesday = addDays(saturday, 3); - return { - open, - start: midnight(saturday.year, saturday.month, saturday.day), - end: midnight(tuesday.year, tuesday.month, tuesday.day) - }; - } - - /** Whether the trial is still offered: fewer than {@link PAID_SEATS} teams pay. */ - export async function offered(): Promise { - const [row] = await Database.use((tx) => - tx - .select({ paid: count() }) - .from(TeamTable) - .where(and(eq(TeamTable.plan, 'paid'), isNull(TeamTable.timeDeleted))) - ); - return (row?.paid ?? 0) < PAID_SEATS; - } - - /** Live seconds of a person's trial runs inside `[start, end)`, up to `now`. */ - export async function usedSeconds(userId: string, w: Window, at: Date = now()) { - const [row] = await Database.use((tx) => - tx - .select({ - seconds: sql`coalesce(sum(greatest(0, extract(epoch from - least(coalesce(${SessionTable.timeStopped}, ${at.toISOString()}::timestamptz), ${w.end.toISOString()}::timestamptz) - - greatest(${SessionTable.timeStarted}, ${w.start.toISOString()}::timestamptz)))), 0)` - }) - .from(SessionTable) - .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) - .where( - and( - eq(BoxTable.userId, userId), - eq(SessionTable.trial, true), - sql`${SessionTable.timeStarted} is not null`, - isNull(SessionTable.timeDeleted) - ) - ) - ); - return Math.floor(Number(row?.seconds ?? 0)); - } - - function when(at: Date): string { - return new Intl.DateTimeFormat('en-GB', { - timeZone: ZONE, - weekday: 'long', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short' - }).format(at); - } - - /** - * Refuse a trial run that may not start, or record the claim and let it. - * - * The order is the order a person can act on: closed for everyone first, - * then "not you" (somebody already used this address or account), then - * "not now" (outside the window, or this window's hours are spent). - */ - export const assertMayStart = fn( - z.object({ - teamId: z.string(), - userId: z.string(), - linkedAccountId: z.string(), - now: z.date().optional() - }), - async (input) => { - const at = input.now ?? now(); - if (!(await offered())) { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.ACCOUNT_RESTRICTED, - 'The free weekend has closed: Nestri GPUs are now taken by paying teams. A plan gets you on.' - ); - } - - await claim(input); - - const w = window(at); - if (!w.open) { - throw new VisibleError( - 'rate_limit', - ErrorCodes.RateLimit.QUOTA_EXCEEDED, - `Free play on Nestri GPUs is Saturday to Monday. The next window opens ${when(w.start)}.` - ); - } - if ((await usedSeconds(input.userId, w, at)) >= SECONDS) { - const next = window(w.end); - throw new VisibleError( - 'rate_limit', - ErrorCodes.RateLimit.QUOTA_EXCEEDED, - `Your ${HOURS} free hours this weekend are used. The next window opens ${when(next.start)}.` - ); - } - } - ); - - /** - * The team's claim, made on its first trial run. - * - * Checked by reading, then held by the unique indexes: two first runs in - * the same instant both read "unclaimed", and the second insert is refused - * with the same answer the read would have given. - */ - async function claim(input: { teamId: string; userId: string; linkedAccountId: string }) { - const existing = await Database.use((tx) => - tx.select().from(TrialClaimTable).where(eq(TrialClaimTable.teamId, input.teamId)) - ); - if (existing.length > 0) return; - - const [person] = await Database.use((tx) => - tx.select({ email: UserTable.email }).from(UserTable).where(eq(UserTable.id, input.userId)) - ); - const [linked] = await Database.use((tx) => - tx - .select({ steamId: LinkedAccountTable.providerAccountId }) - .from(LinkedAccountTable) - .where(eq(LinkedAccountTable.id, input.linkedAccountId)) - ); - const email = person?.email?.trim().toLowerCase(); - if (!email || !linked) { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.ACCOUNT_RESTRICTED, - 'The free weekend needs a verified email address and a linked Steam account.' - ); - } - - const used = () => - new VisibleError( - 'forbidden', - ErrorCodes.Permission.ACCOUNT_RESTRICTED, - 'This email address or Steam account has already had the free weekend. A plan gets you on.' - ); - const taken = await Database.use((tx) => - tx - .select({ id: TrialClaimTable.id }) - .from(TrialClaimTable) - .where( - sql`lower(${TrialClaimTable.email}) = ${email} or ${TrialClaimTable.steamId} = ${linked.steamId}` - ) - .limit(1) - ); - if (taken.length > 0) throw used(); - - try { - await Database.use((tx) => - tx.insert(TrialClaimTable).values({ - id: Identifier.ascending('trialClaim'), - teamId: input.teamId, - userId: input.userId, - email, - steamId: linked.steamId - }) - ); - } catch (err) { - const e = err as { code?: string; cause?: { code?: string } }; - if (e?.code === '23505' || e?.cause?.code === '23505') throw used(); - throw err; - } - } - - /** - * The trial runs on one machine that must be stopped now: live, and either - * outside the window or past the hours. Asked when the host polls for - * work, so a run is stopped within one poll of crossing the line. - */ - export async function overdue(machineId: string, at: Date = now()) { - const live = await Database.use((tx) => - tx - .select({ sessionId: SessionTable.id, boxId: BoxTable.id, userId: BoxTable.userId }) - .from(SessionTable) - .innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id)) - .innerJoin(MachineTable, eq(BoxTable.machineId, MachineTable.id)) - .where( - and( - eq(BoxTable.machineId, machineId), - eq(SessionTable.trial, true), - eq(SessionTable.state, 'live'), - isNull(SessionTable.timeDeleted) - ) - ) - ); - const w = window(at); - const out: { sessionId: string; boxId: string; reason: 'window' | 'hours' }[] = []; - for (const run of live) { - if (!w.open) out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'window' }); - else if ((await usedSeconds(run.userId, w, at)) >= SECONDS) { - out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'hours' }); - } - } - return out; - } -} From 5a399686cae4eb3196786ec1307f3da92e3b3d25 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 19 Oct 2026 22:07:20 +0300 Subject: [PATCH 2/2] fix(api): rooms book cards atomically, free never gets a non-entry card, slots identify GPUs Create a room under a per-user advisory lock (limit, card and insert in one transaction) and book cards under per-machine locks. A free plan with no model is placed on an entry card only. Boxes with no card hold their machine's first card; the new migration backfills single-GPU fleet boxes and adds box.gpu_slot. Machine.Gpu accepts slot and renderNode; session.start carries gpuSlot and gpuPciId. A stopped room re-checks its card when a run is requested. --- apps/api/app/routes/box.ts | 36 +- apps/api/app/routes/session.ts | 5 + apps/api/test/rooms.test.ts | 92 + apps/api/test/session.test.ts | 4 +- .../core/migrations/0030_box_gpu_slot.sql | 11 + .../core/migrations/meta/0030_snapshot.json | 4059 +++++++++++++++++ packages/core/migrations/meta/_journal.json | 7 + packages/core/src/box/box.sql.ts | 6 + packages/core/src/box/index.ts | 36 +- packages/core/src/box/location.ts | 4 + packages/core/src/box/placement.ts | 314 +- packages/core/src/machine/index.ts | 15 +- packages/core/src/machine/machine.sql.ts | 2 +- packages/core/src/session/index.ts | 13 +- 14 files changed, 4502 insertions(+), 102 deletions(-) create mode 100644 packages/core/migrations/0030_box_gpu_slot.sql create mode 100644 packages/core/migrations/meta/0030_snapshot.json diff --git a/apps/api/app/routes/box.ts b/apps/api/app/routes/box.ts index d856d65b..9fd8de16 100644 --- a/apps/api/app/routes/box.ts +++ b/apps/api/app/routes/box.ts @@ -52,13 +52,17 @@ export namespace BoxApi { pciId: z.string().optional().meta({ description: 'With `on: "own"`: the GPU on that host, as `GET /location` lists it' }), + slot: z.string().optional().meta({ + description: + 'With `on: "own"`: the card’s PCI address, to tell apart cards of one model' + }), model: z.string().optional().meta({ description: 'With `on: "fleet"`: the GPU model, as `GET /location` lists it' }) }) ), async (c) => { - const { label, tier, on, machineId, pciId, model } = c.req.valid('json'); + const { label, tier, on, machineId, pciId, slot, model } = c.req.valid('json'); const userId = Actor.userID; if (on === 'own' && Boolean(machineId) !== Boolean(pciId)) { throw new VisibleError( @@ -68,6 +72,8 @@ export namespace BoxApi { machineId ? 'pciId' : 'machineId' ); } + let guard: (() => Promise) | undefined; + let placer: Placement.Placer; if (on === 'fleet') { // Plans count cloud rooms; your own GPUs are never limited. const team = await Team.personalFor(userId); @@ -75,21 +81,25 @@ export namespace BoxApi { if (model && !Rooms.mayUseModel(plan, model)) { throw Rooms.needsPro(model); } - if ((await Billing.cloudRooms(userId)) >= Rooms.allowed(plan)) { - throw Rooms.atLimit(model ?? null, plan); - } - } - const placer = - on === 'fleet' - ? model - ? Placement.onFleetModel(model) - : Placement.fleet - : machineId && pciId - ? Placement.onOwn({ machineId, pciId }) + // Runs inside the creation transaction, under the person's lock. + guard = async () => { + if ((await Billing.cloudRooms(userId)) >= Rooms.allowed(plan)) { + throw Rooms.atLimit(model ?? null, plan); + } + }; + placer = model + ? Placement.onFleetModel(model) + : Placement.onFleetAny({ entryOnly: !Rooms.isPro(plan) }); + } else { + placer = + machineId && pciId + ? Placement.onOwn({ machineId, pciId, slot }) : Placement.onlyHost; + } const box = await Box.createPlaced( { id: Identifier.ascending('box'), userId, label, tier }, - placer + placer, + guard ); return c.json({ data: box }, 201); } diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index 5909dc27..fbb647c8 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -6,6 +6,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; import { Game } from '@nestri/core/game/index'; import { Identifier } from '@nestri/core/id'; +import { Placement } from '@nestri/core/box/placement'; import { Session } from '@nestri/core/session/index'; import { Launch } from '@nestri/core/session/launch'; import { Team } from '@nestri/core/team/index'; @@ -287,6 +288,10 @@ export namespace SessionApi { conflict(Session.BOX_BUSY); } + // A stopped cloud room gave its card up: hold one again (its own, + // or the same model elsewhere) or refuse with 429. + await Placement.reclaim(box.id); + // Plans count rooms, not time: nothing here refuses a run for time // spent. The standing is returned so a client can show it. const payer = await Billing.teamForBox(box.id); diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts index 9f388526..a182ca7e 100644 --- a/apps/api/test/rooms.test.ts +++ b/apps/api/test/rooms.test.ts @@ -266,3 +266,95 @@ describe('the idle limit', () => { expect((await Box.fromID(box.id))!.state).toBe('running'); }); }); + +describe('review blockers on rooms', () => { + test('free with no model is placed on an entry card only', async () => { + await fleet([[BIG], [V710]]); + const p = await person('free-any'); + const res = await post(p.headers, { on: 'fleet' }); + expect(res.status).toBe(201); + expect((await json(res)).data.gpuPciId).toBe(V710.pciId); + + const only = await fleet([[BIG]]); + const q = await person('free-none'); + const refused = await post(q.headers, { on: 'fleet' }); + expect(refused.status).toBe(429); + expect(only.machines.length).toBe(1); + }); + + test('two concurrent POST /box for a free user: exactly one room', async () => { + await fleet([[V710], [V710], [V710]]); + const p = await person('race-limit'); + const res = await Promise.all([ + post(p.headers, { on: 'fleet', model: V710.model }), + post(p.headers, { on: 'fleet', model: V710.model }) + ]); + expect(res.map((r) => r.status).sort()).toEqual([201, 403]); + expect(await Box.onFleet({ userId: p.owner.userId, organisationId: Env.get().FLEET_ORGANISATION_ID! })) + .toHaveLength(1); + }); + + test('two people racing for the last card: one gets it', async () => { + await fleet([[V710]]); + const a = await person('race-a'); + const b = await person('race-b'); + const res = await Promise.all([ + post(a.headers, { on: 'fleet', model: V710.model }), + post(b.headers, { on: 'fleet', model: V710.model }) + ]); + expect(res.map((r) => r.status).sort()).toEqual([201, 429]); + }); + + test('a box with no card holds its machine’s first card; slots tell identical cards apart', async () => { + const f = await fleet([[V710]]); + const legacy = await person('legacy'); + await Box.create({ + id: Identifier.ascending('box'), + userId: legacy.owner.userId, + machineId: f.machines[0]!.id, + gpuPciId: null, + label: 'old', + tier: 'md' + }); + const other = await person('legacy-other', 'paid'); + expect((await post(other.headers, { on: 'fleet', model: V710.model })).status).toBe(429); + const loc = (await json(await app.request('/location', { headers: other.headers }))).data; + expect(loc.fleet[0].available).toBe(false); + + const g = await fleet([[ + { ...V710, slot: '0000:45:00.0' }, + { ...V710, slot: '0000:46:00.0' } + ]]); + const p = await person('slots', 'paid'); + const one = (await json(await post(p.headers, { on: 'fleet', model: V710.model }))).data; + const two = (await json(await post(p.headers, { on: 'fleet', model: V710.model }))).data; + expect([one.gpuSlot, two.gpuSlot].sort()).toEqual(['0000:45:00.0', '0000:46:00.0']); + expect(g.machines.length).toBe(1); + const jobs = (await import('@nestri/core/session/index')).Session; + expect(jobs.StartJob.shape.gpuSlot).toBeDefined(); + }); + + test('a stopped room whose card was taken is re-placed on the same model, or refused', async () => { + const f = await fleet([[V710], [V710]]); + const a = await person('reclaim-a'); + const room = (await json(await post(a.headers, { on: 'fleet', model: V710.model }))).data; + await Box.setState({ id: room.id, state: 'stopped', stopReason: 'idle for 3 hours', stopClean: true }); + const b = await person('reclaim-b'); + const bBox = (await json(await post(b.headers, { on: 'fleet', model: V710.model }))).data; + const c = await person('reclaim-c', 'paid'); + const cBox = (await json(await post(c.headers, { on: 'fleet', model: V710.model }))).data; + expect(cBox.machineId).toBe(room.machineId); + expect(bBox.machineId).not.toBe(room.machineId); + + const { Placement } = await import('@nestri/core/box/placement'); + await expect(Placement.reclaim(room.id)).rejects.toThrow(/free right now/); + expect((await Box.fromID(room.id))!.state).toBe('stopped'); + + await Box.setState({ id: bBox.id, state: 'stopped', stopReason: 'x', stopClean: true }); + await Placement.reclaim(room.id); + const moved = (await Box.fromID(room.id))!; + expect(moved.machineId).toBe(bBox.machineId); + expect(moved.state).toBe('created'); + expect(f.machines.length).toBe(2); + }); +}); diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 23fa50ed..762178f3 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -442,7 +442,9 @@ describe('GET /machine/jobs', () => { steamAppId: 5520, linkedAccountId: s.owner.linkedAccountId, userId: s.owner.userId, - launchId: body.data.launches[0].id + launchId: body.data.launches[0].id, + gpuSlot: null, + gpuPciId: null }); }); diff --git a/packages/core/migrations/0030_box_gpu_slot.sql b/packages/core/migrations/0030_box_gpu_slot.sql new file mode 100644 index 00000000..fdb575d2 --- /dev/null +++ b/packages/core/migrations/0030_box_gpu_slot.sql @@ -0,0 +1,11 @@ +ALTER TABLE "box" ADD COLUMN "gpu_slot" text; +--> statement-breakpoint +-- Boxes made before a box named its card hold one card of their machine. +-- Where the machine has exactly one GPU that card is known, so name it. +UPDATE "box" SET "gpu_pci_id" = m."gpus"->0->>'pciId' +FROM "machine" m +WHERE "box"."machine_id" = m."id" + AND "box"."gpu_pci_id" IS NULL + AND m."organisation_id" IS NOT NULL + AND jsonb_typeof(m."gpus") = 'array' + AND jsonb_array_length(m."gpus") = 1; diff --git a/packages/core/migrations/meta/0030_snapshot.json b/packages/core/migrations/meta/0030_snapshot.json new file mode 100644 index 00000000..c3ca9c98 --- /dev/null +++ b/packages/core/migrations/meta/0030_snapshot.json @@ -0,0 +1,4059 @@ +{ + "id": "f3b9bc09-a1a9-4e38-bccb-1867d6df15f8", + "prevId": "b0f0a475-ecd4-4ac9-8fff-de9d63e4136b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_counter": { + "name": "burn_counter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "five_hour_usage": { + "name": "five_hour_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "five_hour_at": { + "name": "five_hour_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "seven_day_usage": { + "name": "seven_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seven_day_at": { + "name": "seven_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "thirty_day_usage": { + "name": "thirty_day_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thirty_day_at": { + "name": "thirty_day_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_counter_team_unique": { + "name": "burn_counter_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_counter_team_id_team_id_fk": { + "name": "burn_counter_team_id_team_id_fk", + "tableFrom": "burn_counter", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.burn_segment": { + "name": "burn_segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "rate_milli": { + "name": "rate_milli", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "burn_segment_team_idx": { + "name": "burn_segment_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_session_idx": { + "name": "burn_segment_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "burn_segment_one_open_per_session": { + "name": "burn_segment_one_open_per_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"burn_segment\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "burn_segment_team_id_team_id_fk": { + "name": "burn_segment_team_id_team_id_fk", + "tableFrom": "burn_segment", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "burn_segment_session_id_session_id_fk": { + "name": "burn_segment_session_id_session_id_fk", + "tableFrom": "burn_segment", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gpu_pci_id": { + "name": "gpu_pci_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gpu_slot": { + "name": "gpu_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'md'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.install_token": { + "name": "install_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "install_token_hash_unique": { + "name": "install_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "install_token_team_idx": { + "name": "install_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "install_token_team_id_team_id_fk": { + "name": "install_token_team_id_team_id_fk", + "tableFrom": "install_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_organisation_id_organisation_id_fk": { + "name": "install_token_organisation_id_organisation_id_fk", + "tableFrom": "install_token", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_created_by_user_id_user_id_fk": { + "name": "install_token_created_by_user_id_user_id_fk", + "tableFrom": "install_token", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "install_token_machine_id_machine_id_fk": { + "name": "install_token_machine_id_machine_id_fk", + "tableFrom": "install_token", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "install_token_one_owner": { + "name": "install_token_one_owner", + "value": "(\"install_token\".\"team_id\" is null) != (\"install_token\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gpus": { + "name": "gpus", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slots": { + "name": "slots", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.launch": { + "name": "launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "launch_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "first": { + "name": "first", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "launch_session_idx": { + "name": "launch_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "launch_state_idx": { + "name": "launch_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "launch_session_id_session_id_fk": { + "name": "launch_session_id_session_id_fk", + "tableFrom": "launch", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "launch_game_id_game_id_fk": { + "name": "launch_game_id_game_id_fk", + "tableFrom": "launch", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stop_requested": { + "name": "time_stop_requested", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_claimed": { + "name": "time_claimed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": [ + "machine_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_link_request": { + "name": "steam_link_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_link_request_nonce_unique": { + "name": "steam_link_request_nonce_unique", + "columns": [ + { + "expression": "nonce_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "steam_link_request_user_idx": { + "name": "steam_link_request_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_link_request_user_id_user_id_fk": { + "name": "steam_link_request_user_id_user_id_fk", + "tableFrom": "steam_link_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_claim": { + "name": "trial_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "trial_claim_email_unique": { + "name": "trial_claim_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_steam_unique": { + "name": "trial_claim_steam_unique", + "columns": [ + { + "expression": "steam_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_team_unique": { + "name": "trial_claim_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claim_user_idx": { + "name": "trial_claim_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claim_team_id_team_id_fk": { + "name": "trial_claim_team_id_team_id_fk", + "tableFrom": "trial_claim", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trial_claim_user_id_user_id_fk": { + "name": "trial_claim_user_id_user_id_fk", + "tableFrom": "trial_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.launch_state": { + "name": "launch_state", + "schema": "public", + "values": [ + "requested", + "running", + "ended", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": [ + "enrolled", + "stale", + "revoked" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 94a82599..5bb012e3 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1792436256695, "tag": "0029_box_gpu_pci_id", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1792436540630, + "tag": "0030_box_gpu_slot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/box/box.sql.ts b/packages/core/src/box/box.sql.ts index dbdad944..e8a7f2b6 100644 --- a/packages/core/src/box/box.sql.ts +++ b/packages/core/src/box/box.sql.ts @@ -63,6 +63,12 @@ export const BoxTable = pgTable( * (`vendor:device`). Null on boxes made before a box named its card. */ gpuPciId: text('gpu_pci_id'), + /** + * The card's PCI address on that host (`0000:45:00.0`), when the host + * reports slots. Told apart from `gpuPciId`, which is only vendor:device + * and is the same for every card of one model. + */ + gpuSlot: text('gpu_slot'), tier: BoxTier('tier').notNull().default('md'), state: BoxState('state').notNull().default('created'), /** Why it stopped, verbatim from the agent. Null while it has never run. */ diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index 84e4f7c1..2e8a2031 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -38,6 +38,11 @@ export namespace Box { 'The GPU on its host the box is pinned to (`vendor:device`). Null for a box made before boxes named a card', example: null }), + gpuSlot: z.string().nullable().optional().meta({ + description: + 'The PCI address of that card on its host, when the host reports slots. Null otherwise', + example: null + }), label: z.string().meta({ description: 'Editable display name. Not unique, and not the DNS label', example: Examples.Box.label @@ -87,6 +92,7 @@ export namespace Box { userId: true, machineId: true, gpuPciId: true, + gpuSlot: true, label: true, tier: true }), @@ -99,6 +105,7 @@ export namespace Box { userId: input.userId, machineId: input.machineId, gpuPciId: input.gpuPciId ?? null, + gpuSlot: input.gpuSlot ?? null, label: input.label, tier: input.tier }) @@ -119,16 +126,26 @@ export namespace Box { */ export const createPlaced = async ( input: { id: string; userId: string; label: string; tier: Info['tier'] }, - placer?: Placement.Placer + placer?: Placement.Placer, + guard?: () => Promise ) => { - const placed = await Placement.choose({ userId: input.userId, tier: input.tier }, placer); - return create({ - id: input.id, - userId: input.userId, - machineId: placed.machineId, - gpuPciId: placed.gpuPciId, - label: input.label, - tier: input.tier + // One transaction under a per-person lock: the limit check, the card + // choice and the insert are one step, so two requests from one person + // cannot both pass a limit of one. Card booking is locked per machine + // inside the placer. + return Database.transaction(async () => { + await Placement.lock([`user:${input.userId}`]); + await guard?.(); + const placed = await Placement.choose({ userId: input.userId, tier: input.tier }, placer); + return create({ + id: input.id, + userId: input.userId, + machineId: placed.machineId, + gpuPciId: placed.gpuPciId, + gpuSlot: placed.gpuSlot ?? null, + label: input.label, + tier: input.tier + }); }); }; @@ -488,6 +505,7 @@ export namespace Box { userId: input.userId, machineId: input.machineId, gpuPciId: input.gpuPciId, + gpuSlot: input.gpuSlot, label: input.label, tier: input.tier as Info['tier'], state: input.state as Info['state'], diff --git a/packages/core/src/box/location.ts b/packages/core/src/box/location.ts index 85d12fdf..0595a5bd 100644 --- a/packages/core/src/box/location.ts +++ b/packages/core/src/box/location.ts @@ -16,6 +16,9 @@ export namespace Location { .object({ machineId: z.string().meta({ description: 'The host the card is in' }), pciId: z.string().meta({ description: 'vendor:device, as the host reported it' }), + slot: z.string().nullable().meta({ + description: 'PCI address on the host, when it reports one. Tells apart cards of one model' + }), model: z.string().meta({ description: 'What the card is called' }), vramMib: z.number().int().nullable().meta({ description: 'Card memory in MiB, if reported' }), online: z.boolean().meta({ description: 'Whether its host is reporting in' }) @@ -48,6 +51,7 @@ export namespace Location { (m.gpus ?? []).map((g) => ({ machineId: m.id, pciId: g.pciId, + slot: g.slot ?? null, model: g.model, vramMib: g.vramMib ?? null, online: Machine.isOnline(m.lastSeen ?? null) diff --git a/packages/core/src/box/placement.ts b/packages/core/src/box/placement.ts index 0af1fbf3..d84526f5 100644 --- a/packages/core/src/box/placement.ts +++ b/packages/core/src/box/placement.ts @@ -1,4 +1,4 @@ -import { and, count, eq, inArray, isNotNull, isNull, ne } from 'drizzle-orm'; +import { and, count, eq, inArray, isNull, ne, sql } from 'drizzle-orm'; import z from 'zod'; import { Database } from '../db/index.js'; @@ -6,6 +6,7 @@ import { Env } from '../env.js'; import { ErrorCodes, VisibleError } from '../error.js'; import { Machine } from '../machine/index.js'; import { MachineTable } from '../machine/machine.sql.js'; +import { Rooms } from '../billing/rooms.js'; import { BoxTable, BoxTier } from './box.sql.js'; /** @@ -42,6 +43,22 @@ export namespace Placement { export interface Placed { machineId: string; gpuPciId: string | null; + /** The card's PCI address, when its host reports slots. */ + gpuSlot?: string | null; + } + + /** + * Serialise whoever books the same thing, for the rest of the transaction. + * Keyed by a name (`user:`, `machine:`); in a transaction it is + * released at commit, outside one it is a no-op. Always take several in + * sorted order. + */ + export async function lock(keys: string[]) { + for (const key of [...new Set(keys)].sort()) { + await Database.use((tx) => + tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`) + ); + } } /** @@ -156,94 +173,172 @@ export namespace Placement { * with the same answer for both so a stranger's host id learns nothing. */ export const onOwn = - (target: { machineId: string; pciId: string }): Placer => + (target: { machineId: string; pciId: string; slot?: string }): Placer => async (request) => { const host = (await Machine.listByOwner(request.userId)).find( (m) => m.id === target.machineId ); - if (!host || !host.gpus?.some((g) => g.pciId === target.pciId)) { + const cards = (host?.gpus ?? []).filter( + (g) => g.pciId === target.pciId && (!target.slot || g.slot === target.slot) + ); + if (!host || cards.length === 0) { throw new VisibleError( 'not_found', ErrorCodes.NotFound.RESOURCE_NOT_FOUND, 'No such GPU on a host of yours' ); } - return { machineId: host.id, gpuPciId: target.pciId }; + return { + machineId: host.id, + gpuPciId: target.pciId, + gpuSlot: target.slot ?? (cards.length === 1 ? (cards[0]!.slot ?? null) : null) + }; }; + type Gpu = NonNullable[number]; + interface Held { + gpuPciId: string | null; + gpuSlot: string | null; + } + + /** + * Which of a machine's cards the boxes on it hold, as booleans in `gpus` + * order. Boxes that name a slot hold that card; boxes that name only a + * model hold the first unheld card of it; boxes from before a box named a + * card (null) hold one card of the machine's first GPU, since all they + * were ever given was that. + */ + export function heldCards(gpus: readonly Gpu[], boxes: readonly Held[]): boolean[] { + const taken = gpus.map(() => false); + const take = (match: (g: Gpu) => boolean) => { + const i = gpus.findIndex((g, n) => !taken[n] && match(g)); + if (i >= 0) taken[i] = true; + }; + for (const b of boxes) if (b.gpuSlot) take((g) => g.slot === b.gpuSlot); + for (const b of boxes) if (!b.gpuSlot && b.gpuPciId) take((g) => g.pciId === b.gpuPciId); + const first = gpus[0]?.pciId; + for (const b of boxes) if (!b.gpuSlot && !b.gpuPciId && first) take((g) => g.pciId === first); + return taken; + } + + export interface Card { + machineId: string; + pciId: string; + slot: string | null; + model: string; + vramMib: number | null; + online: boolean; + free: boolean; + } + /** * The fleet's GPUs, one entry per card, with whether each is free: online - * host, and no room on it that is not stopped. Cards of one model on one - * host that share a `vendor:device` id are told apart only by count. + * host, and no room on it that is not stopped. */ - export async function fleetCards(organisationId: string) { + export async function fleetCards(organisationId: string): Promise { const machines = await Machine.listByOrganisation(organisationId); - const pinned = await Database.use((tx) => - tx - .select({ - machineId: BoxTable.machineId, - pci: BoxTable.gpuPciId, - held: count() - }) - .from(BoxTable) - .where( - and( - inArray( - BoxTable.machineId, - machines.map((m) => m.id) - ), - isNull(BoxTable.timeDeleted), - ne(BoxTable.state, 'stopped'), - isNotNull(BoxTable.gpuPciId) - ) - ) - .groupBy(BoxTable.machineId, BoxTable.gpuPciId) - ); - const held = new Map(pinned.map((p) => [`${p.machineId}/${p.pci}`, p.held])); - const cards: { - machineId: string; - pciId: string; - model: string; - vramMib: number | null; - online: boolean; - free: boolean; - }[] = []; + const rows = + machines.length === 0 + ? [] + : await Database.use((tx) => + tx + .select({ + machineId: BoxTable.machineId, + gpuPciId: BoxTable.gpuPciId, + gpuSlot: BoxTable.gpuSlot + }) + .from(BoxTable) + .where( + and( + inArray( + BoxTable.machineId, + machines.map((m) => m.id) + ), + isNull(BoxTable.timeDeleted), + ne(BoxTable.state, 'stopped') + ) + ) + ); + const cards: Card[] = []; for (const m of machines) { const online = Machine.isOnline(m.lastSeen ?? null); - const taken = new Map(); - for (const g of m.gpus ?? []) { - const seen = taken.get(g.pciId) ?? 0; - taken.set(g.pciId, seen + 1); + const gpus = m.gpus ?? []; + const taken = heldCards( + gpus, + rows.filter((r) => r.machineId === m.id) + ); + gpus.forEach((g, i) => cards.push({ machineId: m.id, pciId: g.pciId, + slot: g.slot ?? null, model: g.model, vramMib: g.vramMib ?? null, online, - // The n-th identical card is free when fewer than n rooms hold that id. - free: online && (held.get(`${m.id}/${g.pciId}`) ?? 0) < seen + 1 - }); - } + free: online && !taken[i] + }) + ); } return cards; } + function fleetOrganisation() { + const organisationId = Env.get().FLEET_ORGANISATION_ID; + if (!organisationId) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + 'Nestri GPUs are not available on this deployment' + ); + } + return organisationId; + } + + /** Free cards, after taking the booking lock of every fleet machine. */ + async function lockedFreeCards(organisationId: string) { + await lock( + (await Machine.listByOrganisation(organisationId)).map((m) => `machine:${m.id}`) + ); + return (await fleetCards(organisationId)).filter((c) => c.free); + } + + /** Of free cards, the one on the machine holding the fewest boxes for its slots. */ + async function leastLoaded(free: Card[]): Promise { + const ids = [...new Set(free.map((c) => c.machineId))]; + const counts = await Database.use((tx) => + tx + .select({ + id: MachineTable.id, + slots: MachineTable.slots, + boxes: count(BoxTable.id) + }) + .from(MachineTable) + .leftJoin( + BoxTable, + and(eq(BoxTable.machineId, MachineTable.id), isNull(BoxTable.timeDeleted)) + ) + .where(inArray(MachineTable.id, ids)) + .groupBy(MachineTable.id) + ); + const load = new Map(counts.map((c) => [c.id, c.boxes / Math.max(1, c.slots ?? 1)])); + return [...free].sort((a, b) => (load.get(a.machineId) ?? 0) - (load.get(b.machineId) ?? 0))[0]!; + } + + const placedOn = (c: Card): Placed => ({ + machineId: c.machineId, + gpuPciId: c.pciId, + gpuSlot: c.slot + }); + /** * Place it on a fleet GPU of one model: of the hosts with a free card of - * that model, the one holding the fewest boxes for its slots. + * that model, the one holding the fewest boxes for its slots. Cards are + * booked under a per-machine lock, so two callers never get the same one. */ export const onFleetModel = (model: string): Placer => async () => { - const organisationId = Env.get().FLEET_ORGANISATION_ID; - if (!organisationId) { - throw new VisibleError( - 'internal', - ErrorCodes.Server.SERVICE_UNAVAILABLE, - 'Nestri GPUs are not available on this deployment' - ); - } - const free = (await fleetCards(organisationId)).filter((c) => c.model === model && c.free); + const free = (await lockedFreeCards(fleetOrganisation())).filter((c) => c.model === model); if (free.length === 0) { throw new VisibleError( 'rate_limit', @@ -251,29 +346,96 @@ export namespace Placement { `No ${model} is free right now. Try again in a few minutes` ); } - const ids = [...new Set(free.map((c) => c.machineId))]; - const counts = await Database.use((tx) => - tx - .select({ - id: MachineTable.id, - slots: MachineTable.slots, - boxes: count(BoxTable.id) - }) - .from(MachineTable) - .leftJoin( - BoxTable, - and(eq(BoxTable.machineId, MachineTable.id), isNull(BoxTable.timeDeleted)) - ) - .where(inArray(MachineTable.id, ids)) - .groupBy(MachineTable.id) - ); - const load = new Map(counts.map((c) => [c.id, c.boxes / Math.max(1, c.slots ?? 1)])); - const pick = [...free].sort( - (a, b) => (load.get(a.machineId) ?? 0) - (load.get(b.machineId) ?? 0) - )[0]!; - return { machineId: pick.machineId, gpuPciId: pick.pciId }; + return placedOn(await leastLoaded(free)); }; + /** + * Place it on whichever fleet card is free and least loaded, when no model + * was named. With `entryOnly` (every plan but Pro) only entry models are + * considered. A fleet whose hosts report no cards at all (nothing to protect) falls back to + * {@link fleet}, which places on a machine and names no card. + */ + export const onFleetAny = + (opts: { entryOnly: boolean }): Placer => + async (request) => { + const organisationId = fleetOrganisation(); + const free = (await lockedFreeCards(organisationId)).filter( + (c) => !opts.entryOnly || Rooms.isEntry(c.model) + ); + if (free.length === 0) { + const reported = (await fleetCards(organisationId)).length > 0; + if (!reported) return fleet(request); + throw new VisibleError( + 'rate_limit', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + 'No Nestri GPU is free right now. Try again in a few minutes' + ); + } + return placedOn(await leastLoaded(free)); + }; + + /** + * A stopped room is about to run again: make sure a card is held for it. + * Its own card when still free, else a free card of the same model + * elsewhere, else a refusal (429, SERVICE_UNAVAILABLE). Holding is a state + * change to `created`, so nobody else is placed on the card meanwhile. + * Does nothing for a box that is not stopped or not on the fleet. + */ + export async function reclaim(boxId: string): Promise { + const organisationId = Env.get().FLEET_ORGANISATION_ID; + if (!organisationId) return; + await Database.transaction(async () => { + const machines = await Machine.listByOrganisation(organisationId); + await lock(machines.map((m) => `machine:${m.id}`)); + const box = await Database.use((tx) => + tx + .select() + .from(BoxTable) + .where(and(eq(BoxTable.id, boxId), isNull(BoxTable.timeDeleted))) + .then((r) => r[0]) + ); + if (!box || box.state !== 'stopped') return; + const home = machines.find((m) => m.id === box.machineId); + if (!home) return; + const cards = await fleetCards(organisationId); + const first = home.gpus?.[0]; + const pci = box.gpuPciId ?? first?.pciId ?? null; + const mine = cards.find( + (c) => + c.machineId === box.machineId && + c.free && + (box.gpuSlot ? c.slot === box.gpuSlot : c.pciId === pci) + ); + const model = (home.gpus ?? []).find((g) => g.pciId === pci)?.model ?? first?.model; + const target = + mine ?? + (model && cards.find((c) => c.free && c.model === model + ) + ? await leastLoaded(cards.filter((c) => c.free && c.model === model)) + : null); + if (!target) { + throw new VisibleError( + 'rate_limit', + ErrorCodes.Server.SERVICE_UNAVAILABLE, + `No ${model ?? 'Nestri GPU'} is free right now. Try again in a few minutes` + ); + } + await Database.use((tx) => + tx + .update(BoxTable) + .set({ + state: 'created', + stopReason: null, + stopClean: null, + machineId: target.machineId, + gpuPciId: target.pciId, + gpuSlot: target.slot + }) + .where(eq(BoxTable.id, boxId)) + ); + }); + } + /** Place a box, using `onlyHost` unless a caller supplies its own placer. */ export async function choose(request: Request, placer: Placer = onlyHost): Promise { return placer(Request.parse(request)); diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 8258554f..04cebeb3 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -57,7 +57,20 @@ export namespace Machine { .meta({ description: 'vendor:device, the ids the model was looked up by', example: '1002:7590' - }) + }), + slot: z + .string() + .regex(/^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]$/) + .optional() + .meta({ + description: + 'The card’s PCI address on the host. Tells apart cards of one model; absent from hosts that do not report it', + example: '0000:45:00.0' + }), + renderNode: z.string().max(64).optional().meta({ + description: 'The card’s DRM render node on the host. Absent when not reported', + example: '/dev/dri/renderD132' + }) }) .meta({ ref: 'Gpu' }); diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index bc13a474..caae25e0 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -101,7 +101,7 @@ export const MachineTable = pgTable( // calls the machine ("AMD Radeon RX 9060 XT"), and the first measured part // of the capacity a scheduler will place by. Null until a report carries // them; never filled in by guessing. - gpus: jsonb('gpus').$type<{ model: string; vramMib?: number; pciId: string }[]>(), + gpus: jsonb('gpus').$type<{ model: string; vramMib?: number; pciId: string; slot?: string; renderNode?: string }[]>(), // How many runs this host will hold at once, as its last heartbeat said. // Null for an agent that does not say, which is handed every waiting // run as before; a number is what makes the rest wait their turn. diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index c7f00bc4..7ade68b7 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -479,6 +479,15 @@ export namespace Session { description: 'The run’s first launch: the game above. Report its exit against it, and give it to the launched process as `NESTRI_LAUNCH_ID`. Null only for a run older than launches', example: Examples.Launch.id + }), + gpuSlot: z.string().nullable().optional().meta({ + description: + 'The PCI address of the card the box is pinned to. Preferred over `gpuPciId` when present; null when the box or its host never named one', + example: null + }), + gpuPciId: z.string().nullable().optional().meta({ + description: 'The card as vendor:device, for a host that does not report slots', + example: null }) }) .meta({ @@ -598,7 +607,9 @@ export namespace Session { steamAppId: row.game.steamAppId, linkedAccountId: row.session.linkedAccountId, userId: row.box.userId, - launchId: row.launchId + launchId: row.launchId, + gpuSlot: row.box.gpuSlot, + gpuPciId: row.box.gpuPciId })) ) );