diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 6dd9153b..ed5efbf4 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -4,6 +4,7 @@ import { Examples } from '@nestri/core/examples'; import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; import { Member } from '@nestri/core/team/member'; +import { Team } from '@nestri/core/team/index'; import { Hono } from 'hono'; import { describeRoute } from 'hono-openapi'; import { z } from 'zod'; @@ -56,7 +57,8 @@ export namespace MachineApi { example: Examples.Machine.label }), teamId: z.string().optional().meta({ - description: 'Register the box into a team rather than to the user alone' + description: + 'Team to own this hardware. Defaults to the caller’s personal team, which always exists' }) }) ), @@ -74,10 +76,37 @@ export namespace MachineApi { ); } + // `machine.teamId` is notNull since 0048, so a team has to be + // resolved rather than defaulted to null. The order is: what the + // caller asked for, then the team they are acting inside, then + // their personal team — which `ensurePersonal` makes if this is a + // user who predates 0048 and has none. + const owningTeam = + teamId ?? + (actor.type === 'member' + ? actor.properties.teamID + : await Team.ensurePersonal({ displayName: Actor.userID })); + + // A caller naming a team must belong to it. Without this, `teamId` + // would be a way to park hardware in somebody else's team. + if (teamId) { + const membership = await Member.findByTeamAndUser({ + teamId, + userId: Actor.userID + }); + if (!membership) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You are not a member of that team' + ); + } + } + const registered = await Machine.register({ id: Identifier.ascending('machine'), ownerUserId: Actor.userID, - teamId: teamId ?? (actor.type === 'member' ? actor.properties.teamID : null), + teamId: owningTeam, label }); @@ -89,9 +118,9 @@ export namespace MachineApi { notPublic, describeRoute({ tags: ['Machine'], - summary: 'Move a box into a team, or out of one', + summary: 'Move a box to another team', description: - 'Scope a machine you own to a team you belong to, or pass teamId: null to make it yours alone again. This is not ownership transfer — the owner does not change.', + 'Move a machine you own to a team you belong to. Hardware always belongs to exactly one team since 0048, so there is no way to unscope — name your personal team instead. This is not ownership transfer: the owner does not change.', responses: { 200: { content: { 'application/json': { schema: Result(Machine.Info) } }, @@ -105,8 +134,9 @@ export namespace MachineApi { validator( 'json', z.object({ - teamId: z.string().nullable().meta({ - description: 'Team to scope the box to, or null to scope it to you alone' + teamId: z.string().meta({ + description: + 'Team to move the box to. There is no “no team” — to unscope, name your personal team' }) }) ), @@ -125,18 +155,16 @@ export namespace MachineApi { // Verified before the write. `setTeam` scopes to the owner but // knows nothing about who belongs to the target team, so this is // the only place that check exists. - if (teamId) { - const membership = await Member.findByTeamAndUser({ - teamId, - userId: Actor.userID - }); - if (!membership) { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.FORBIDDEN, - 'You are not a member of that team' - ); - } + const membership = await Member.findByTeamAndUser({ + teamId, + userId: Actor.userID + }); + if (!membership) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You are not a member of that team' + ); } const machine = await Machine.setTeam({ diff --git a/apps/api/test/routes.test.ts b/apps/api/test/routes.test.ts index 2d2e4eef..fa191bbd 100644 --- a/apps/api/test/routes.test.ts +++ b/apps/api/test/routes.test.ts @@ -378,16 +378,18 @@ describe('Box access', () => { const res = await app.request('/machine/mch_whatever', { method: 'PATCH', headers: { ...adminHeaders(), 'content-type': 'application/json' }, - body: JSON.stringify({ teamId: null }) + body: JSON.stringify({ teamId: 'tem_whatever' }) }); expect(res.status).toBe(403); const body = (await res.json()) as any; expect(body.message).toContain('user session'); }); - test('teamId is required on the body, and may be null', async () => { - // Null is "make it mine alone" — a different thing from omitting the - // field, which would leave the scope ambiguous. + test('teamId is required on the body, and null is no longer a value', async () => { + // Null used to mean "make it mine alone". Since 0048 made + // `machine.teamId` notNull there is no such state — hardware belongs to + // exactly one team and the personal team is the one to name — so null is + // now a validation error rather than a meaning. const missing = await app.request('/machine/mch_whatever', { method: 'PATCH', headers: { ...adminHeaders(), 'content-type': 'application/json' }, @@ -400,8 +402,15 @@ describe('Box access', () => { headers: { ...adminHeaders(), 'content-type': 'application/json' }, body: JSON.stringify({ teamId: null }) }); + expect(explicitNull.status).toBe(400); + + const named = await app.request('/machine/mch_whatever', { + method: 'PATCH', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ teamId: 'tem_whatever' }) + }); // Past validation, refused at the handler for being admin. - expect(explicitNull.status).toBe(403); + expect(named.status).toBe(403); }); test('entitlement requires machine credentials, not a user session', async () => { diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 98aa6e15..0eff3d16 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -6,8 +6,10 @@ import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; import { subjects } from '@nestri/core/auth/subjects'; import { Database } from '@nestri/core/db/index'; import { Env } from '@nestri/core/env'; +import { Actor } from '@nestri/core/actor'; import { Identifier } from '@nestri/core/id'; import { Steam } from '@nestri/core/steam/index'; +import { Team } from '@nestri/core/team/index'; import { User } from '@nestri/core/user/index'; import { LinkedAccount } from '@nestri/core/user/linked-account'; @@ -81,6 +83,19 @@ export default { return { userID: newUserID, linkedAccountID: newLinkedAccountID }; }); + // Every user needs a personal team, because `machine.teamId` is + // notNull since 0048 and registering a host has nowhere to put + // it otherwise. `packages/core/CLAUDE.md` documented this call + // as part of the login flow and it was never actually made, so + // no user in the database has one. + // + // Run on every login rather than only on creation: that is what + // backfills the accounts made before this existed, and + // `ensurePersonal` is idempotent precisely so it can be. + await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => + Team.ensurePersonal({ displayName: personaname }) + ); + return context.subject('user', { userID, linkedAccountID @@ -96,6 +111,16 @@ export default { profile }); + // Same reason as the Steam branch above. The SSH path creates + // users too, so leaving it out would give a host registered + // from `nessh` nowhere to live. + await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => + // `username` is optional on the SSH path — a key can arrive + // before a persona does. The slug only has to be derivable, + // not pretty, and a rename is a later problem. + Team.ensurePersonal({ displayName: username ?? 'Player' }) + ); + return context.subject('user', { userID, linkedAccountID, diff --git a/packages/core/migrations/0007_box_session_team_notnull.sql b/packages/core/migrations/0007_box_session_team_notnull.sql new file mode 100644 index 00000000..b1e6f661 --- /dev/null +++ b/packages/core/migrations/0007_box_session_team_notnull.sql @@ -0,0 +1,130 @@ +-- 0048: a box is a row, a session is the billing unit, and hardware belongs to +-- exactly one team. +-- +-- Three of the five changes here touch live rows, and the generated form of +-- this migration would have failed on all three: +-- +-- 1. `machine.team_id` was nullable and the registration path passed null, so +-- **every existing machine row has a null team_id** and `SET NOT NULL` +-- fails outright. Teams are backfilled below before the constraint lands. +-- 2. `game_download.host_id` was a bare text column — the one place a host was +-- named by a string nothing checked — so it may hold ids of hosts that +-- never existed, and both the cast to char(30) and the new foreign key +-- would fail on them. +-- 3. A personal team did not exist for anybody: `Team.createPersonal` was +-- written and documented but never called. Users without machines are left +-- to `Team.ensurePersonal` on their next login; users *with* machines +-- cannot wait, because their rows are what the constraint is about. + +CREATE TYPE "public"."box_state" AS ENUM('created', 'running', 'stopped');--> statement-breakpoint +CREATE TYPE "public"."box_tier" AS ENUM('xs', 'sm', 'md', 'lg', 'xl');--> statement-breakpoint +CREATE TYPE "public"."session_state" AS ENUM('requested', 'starting', 'live', 'ended', 'failed');--> statement-breakpoint +CREATE TABLE "box" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "user_id" char(30) NOT NULL, + "machine_id" char(30) NOT NULL, + "label" text NOT NULL, + "tier" "box_tier" DEFAULT 'sm' NOT NULL, + "state" "box_state" DEFAULT 'created' NOT NULL, + "stop_reason" text, + "stop_clean" boolean +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "box_id" char(30) NOT NULL, + "game_id" char(30) NOT NULL, + "linked_account_id" char(30) NOT NULL, + "state" "session_state" DEFAULT 'requested' NOT NULL, + "ticket" text, + "time_started" timestamp with time zone, + "time_stopped" timestamp with time zone, + "error_message" text +); +--> statement-breakpoint + +-- Backfill: a personal team for every machine owner who has none. +-- +-- The id is shaped like `Identifier.ascending('team')` — a `tem_` prefix and 26 +-- characters — but is not generated by it, because that lives in TypeScript. +-- Hex is a subset of the base62 alphabet those ids use, so nothing downstream +-- can tell the difference, and neither the prefix nor the length differs. +-- +-- The slug is derived from the owner's user id rather than their display name. +-- It is uglier than what `createPersonal` produces and it is unique by +-- construction, which matters more here: a migration cannot retry a slug +-- collision the way application code can. +INSERT INTO "team" ("id", "name", "slug", "owner_id") +SELECT + 'tem_' || substr(md5(random()::text || clock_timestamp()::text || o."owner_user_id"), 1, 26), + coalesce(nullif(u."name", ''), 'Personal') || '''s Team', + 'personal-' || lower(replace(o."owner_user_id", 'usr_', '')), + o."owner_user_id" +FROM (SELECT DISTINCT "owner_user_id" FROM "machine" WHERE "team_id" IS NULL) o +JOIN "user" u ON u."id" = o."owner_user_id" +WHERE NOT EXISTS ( + SELECT 1 FROM "team" t + WHERE t."owner_id" = o."owner_user_id" AND t."time_deleted" IS NULL +);--> statement-breakpoint + +-- Owners are members of their own team with the `owner` role, which is what +-- `Team.create` does in one transaction. Written as find-or-create so that a +-- team which already existed but somehow lacked its membership row is repaired +-- rather than skipped. +INSERT INTO "team_member" ("id", "team_id", "user_id", "role") +SELECT + 'mem_' || substr(md5(random()::text || clock_timestamp()::text || t."id"), 1, 26), + t."id", + t."owner_id", + 'owner' +FROM "team" t +WHERE t."time_deleted" IS NULL + AND NOT EXISTS ( + SELECT 1 FROM "team_member" tm + WHERE tm."team_id" = t."id" AND tm."user_id" = t."owner_id" + );--> statement-breakpoint + +-- Point every unowned machine at its owner's oldest team, which is the same +-- rule `Team.personalFor` applies. +UPDATE "machine" m +SET "team_id" = ( + SELECT t."id" FROM "team" t + WHERE t."owner_id" = m."owner_user_id" AND t."time_deleted" IS NULL + ORDER BY t."time_created" + LIMIT 1 +) +WHERE m."team_id" IS NULL;--> statement-breakpoint + +-- Drop download rows naming a host that is not a registered machine. +-- +-- This is the only destructive statement in the migration and it is a +-- considered loss: `game_download` is a progress report a host writes about +-- itself, and `neslet` re-derives it from what is on disk. A row that survives +-- here is one whose host we can actually name; one that does not was +-- unattributable, which is exactly the bug the foreign key exists to prevent. +DELETE FROM "game_download" d +WHERE NOT EXISTS ( + SELECT 1 FROM "machine" m WHERE m."id" = d."host_id" +);--> statement-breakpoint + +-- Safe now: every surviving host_id is a machine id, so exactly 30 characters. +ALTER TABLE "game_download" ALTER COLUMN "host_id" SET DATA TYPE char(30);--> statement-breakpoint +ALTER TABLE "machine" ALTER COLUMN "team_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "box" ADD CONSTRAINT "box_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "box" ADD CONSTRAINT "box_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_box_id_box_id_fk" FOREIGN KEY ("box_id") REFERENCES "public"."box"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_linked_account_id_linked_account_id_fk" FOREIGN KEY ("linked_account_id") REFERENCES "public"."linked_account"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "box_user_idx" ON "box" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "box_machine_idx" ON "box" USING btree ("machine_id");--> statement-breakpoint +CREATE INDEX "session_box_idx" ON "session" USING btree ("box_id");--> statement-breakpoint +CREATE INDEX "session_state_idx" ON "session" USING btree ("state");--> statement-breakpoint +CREATE INDEX "session_started_idx" ON "session" USING btree ("time_started");--> statement-breakpoint +ALTER TABLE "game_download" ADD CONSTRAINT "game_download_host_id_machine_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."machine"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "machine" ADD CONSTRAINT "machine_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE restrict ON UPDATE no action; diff --git a/packages/core/migrations/meta/0007_snapshot.json b/packages/core/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..4da6acb2 --- /dev/null +++ b/packages/core/migrations/meta/0007_snapshot.json @@ -0,0 +1,2283 @@ +{ + "id": "6f1c8157-bfc0-4b61-b31e-9e117d8d0510", + "prevId": "6b1eb57f-a4a1-4f5d-a197-1d98e0b7bf7d", + "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.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 + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "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.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": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + } + }, + "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_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": {} + } + }, + "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" + } + }, + "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.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'" + }, + "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 + }, + "error_message": { + "name": "error_message", + "type": "text", + "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_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.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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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": {}, + "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.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.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "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 8e96c85e..b1f7ec6b 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786205230097, "tag": "0006_waitlist_verification_game_aliases", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1788460224524, + "tag": "0007_box_session_team_notnull", + "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 new file mode 100644 index 00000000..73232f9a --- /dev/null +++ b/packages/core/src/box/box.sql.ts @@ -0,0 +1,72 @@ +import { boolean, index, pgEnum, pgTable, text } from 'drizzle-orm/pg-core'; + +import { id, timestamps, ulid } from '../db/types.js'; +import { MachineTable } from '../machine/machine.sql.js'; +import { UserTable } from '../user/user.sql.js'; + +/** + * The named size a box was asked for. + * + * A tier is the unit of sale and it includes output geometry + * ([0021](../../../../.nestri/decisions/0021-vm-size-tiers.md)), so this column + * decides vCPU, RAM *and* the resolution the guest is told to render at. It is + * a *request*: `(tier, gpu_model)` is what admission actually acts on, and + * admission does not exist yet. + */ +export const BoxTier = pgEnum('box_tier', ['xs', 'sm', 'md', 'lg', 'xl']); + +/** + * What the box is doing, in `neslet`'s own vocabulary. + * + * Deliberately the same three states `neslet` reports over its control socket + * and no more. `starting` and `stopping` are the obvious additions and both are + * omitted, because nothing would ever write them: the agent's transitions are + * synchronous from its side, so a state nobody sets is a state that lies. A box + * that failed is `stopped` with `stopClean` false, which is also how `neslet` + * models it — "it is not running" and "it faulted forty seconds ago" are + * different facts, and the difference lives in the reason, not in the state. + */ +export const BoxState = pgEnum('box_state', ['created', 'running', 'stopped']); + +/** + * A VM someone owns. + * + * Nothing represented a box until now + * ([0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)): + * `machine` is the *host*, and a guest had no id a URL could carry, no owner, + * no place, and no state anything could poll. Every screen the desktop app + * still needs is a view over this table. + * + * **A box is owned by a person and placed on a team's hardware, and those are + * two different relationships.** Hence both `userId` and `machineId`: the + * person is who it belongs to and who gets billed through its sessions, the + * machine is where it currently runs. Moving a box to another host changes the + * second and not the first. + */ +export const BoxTable = pgTable( + 'box', + { + ...id, + ...timestamps, + userId: ulid('user_id') + .notNull() + .references(() => UserTable.id, { onDelete: 'cascade' }), + // `restrict` rather than `cascade`: deleting a host must not silently + // delete the boxes someone owns on it. Detaching them is a decision with + // a UI, and there is no UI, so the database refuses instead of guessing. + machineId: ulid('machine_id') + .notNull() + .references(() => MachineTable.id, { onDelete: 'restrict' }), + // The DNS label is the id, per 0019 — `.nestri.link`. This is the + // display string a person edits, and it is deliberately not unique: + // two boxes called "living room" are the owner's problem, not an error. + label: text('label').notNull(), + tier: BoxTier('tier').notNull().default('sm'), + state: BoxState('state').notNull().default('created'), + /** Why it stopped, verbatim from `neslet`. Null while it has never run. */ + stopReason: text('stop_reason'), + /** Whether that stop was a clean exit. Null while it has never run. */ + stopClean: boolean('stop_clean') + }, + (t) => [index('box_user_idx').on(t.userId), index('box_machine_idx').on(t.machineId)] +); diff --git a/packages/core/src/box/box.test.ts b/packages/core/src/box/box.test.ts new file mode 100644 index 00000000..e28039b5 --- /dev/null +++ b/packages/core/src/box/box.test.ts @@ -0,0 +1,141 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { Fixtures } from '../db/fixtures.js'; +import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; +import { Box } from './index.js'; + +const sql = testDb(); + +const createdUserIds: string[] = []; + +async function newOwner(label: string) { + const o = await Fixtures.owner(label); + createdUserIds.push(o.userId); + return o; +} + +afterAll(async () => { + if (createdUserIds.length > 0) { + // Machines cascade from the user, and boxes cascade from both — but the + // box→machine FK is `restrict`, so the box rows have to go first or the + // machine delete is refused. Deleting boxes explicitly says that out loud. + await sql`delete from "box" where user_id in ${sql(createdUserIds)}`; + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + createdUserIds.length = 0; + } +}); + +describe('Box', () => { + test('a new box starts created, sm, and with nothing to explain', async () => { + const owner = await newOwner('box-defaults'); + const machineId = await Fixtures.machine(owner); + + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId, + label: 'living room', + tier: 'sm' + }); + + expect(box.state).toBe('created'); + expect(box.tier).toBe('sm'); + expect(box.stopReason).toBeNull(); + expect(box.stopClean).toBeNull(); + expect(box.id.startsWith('box_')).toBe(true); + }); + + test('a stop records its reason, and starting again clears it', async () => { + const owner = await newOwner('box-stopreason'); + const machineId = await Fixtures.machine(owner); + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId, + label: 'faulty', + tier: 'sm' + }); + + const stopped = await Box.setState({ + id: box.id, + state: 'stopped', + stopReason: 'guest faulted', + stopClean: false + }); + expect(stopped?.state).toBe('stopped'); + expect(stopped?.stopReason).toBe('guest faulted'); + expect(stopped?.stopClean).toBe(false); + + // The point of the test: a box that recovered must not keep explaining a + // failure it is no longer in. + const running = await Box.setState({ + id: box.id, + state: 'running', + stopReason: null, + stopClean: null + }); + expect(running?.state).toBe('running'); + expect(running?.stopReason).toBeNull(); + expect(running?.stopClean).toBeNull(); + }); + + test('renaming is scoped to the owner, so someone else’s box is a miss', async () => { + const owner = await newOwner('box-owner'); + const stranger = await newOwner('box-stranger'); + const machineId = await Fixtures.machine(owner); + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId, + label: 'mine', + tier: 'sm' + }); + + expect(await Box.rename({ id: box.id, userId: stranger.userId, label: 'yours' })).toBeNull(); + expect((await Box.fromID(box.id))?.label).toBe('mine'); + + const renamed = await Box.rename({ id: box.id, userId: owner.userId, label: 'ours' }); + expect(renamed?.label).toBe('ours'); + }); + + test('a box cannot be placed on a machine that does not exist', async () => { + const owner = await newOwner('box-badmachine'); + // The whole reason `machineId` is a foreign key: before 0048 a host was + // named by an unchecked string, so this would have succeeded and produced + // a box on a machine nobody owns. + await expect( + Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId: 'mch_doesnotexistdoesnotexist_', + label: 'nowhere', + tier: 'sm' + }) + ).rejects.toThrow(); + }); + + test('boxes list by user and by machine', async () => { + const owner = await newOwner('box-listing'); + const machineA = await Fixtures.machine(owner, 'host-a'); + const machineB = await Fixtures.machine(owner, 'host-b'); + + for (const [machineId, label] of [ + [machineA, 'a1'], + [machineA, 'a2'], + [machineB, 'b1'] + ] as const) { + await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId, + label, + tier: 'sm' + }); + } + + expect(await Box.listByUser(owner.userId)).toHaveLength(3); + expect((await Box.listByMachine(machineA)).map((b) => b.label)).toEqual(['a1', 'a2']); + expect((await Box.listByMachine(machineB)).map((b) => b.label)).toEqual(['b1']); + }); +}); diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts new file mode 100644 index 00000000..19471d4f --- /dev/null +++ b/packages/core/src/box/index.ts @@ -0,0 +1,185 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { Examples } from '../examples.js'; +import { fn } from '../fn.js'; +import { BoxState, BoxTable, BoxTier } from './box.sql.js'; + +/** + * A VM someone owns. + * + * The box is the thing with a name and a URL ([0010](../../../../.nestri/decisions/0010-the-name-is-the-interface.md), + * [0019](../../../../.nestri/decisions/0019-box-naming.md)); a + * {@link ../session/index.ts | session} is one run of it, and the session is + * what costs money. Keeping them apart is what lets a box be a durable thing a + * person owns rather than a synonym for "currently playing". + */ +export namespace Box { + export const Info = z + .object({ + id: z.string().meta({ + description: 'Unique identifier for the box, and its DNS label', + example: Examples.Box.id + }), + userId: z.string().meta({ + description: 'The person who owns this box', + example: Examples.Box.userId + }), + machineId: z.string().meta({ + description: 'The host it is placed on', + example: Examples.Box.machineId + }), + label: z.string().meta({ + description: 'Editable display name. Not unique, and not the DNS label', + example: Examples.Box.label + }), + tier: z.enum(BoxTier.enumValues).meta({ + description: 'Requested size, which also sets output geometry', + example: Examples.Box.tier + }), + state: z.enum(BoxState.enumValues).meta({ + description: 'What the box is doing, in neslet’s vocabulary', + example: Examples.Box.state + }), + stopReason: z.string().nullable().optional().meta({ + description: 'Why it stopped, verbatim from neslet. Null if it never ran', + example: Examples.Box.stopReason + }), + stopClean: z.boolean().nullable().optional().meta({ + description: 'Whether that stop was clean. Null if it never ran', + example: Examples.Box.stopClean + }) + }) + .meta({ + ref: 'Box', + description: 'A virtual machine owned by a person and placed on a team’s hardware', + example: Examples.Box + }); + + export type Info = z.infer; + + export const create = fn( + Info.pick({ id: true, userId: true, machineId: true, label: true, tier: true }), + async (input) => { + return Database.use(async (tx) => { + return tx + .insert(BoxTable) + .values({ + id: input.id, + userId: input.userId, + machineId: input.machineId, + label: input.label, + tier: input.tier + }) + .returning() + .then((rows) => serialize(rows[0]!)); + }); + } + ); + + export const fromID = fn(Info.shape.id, async (id) => { + return Database.use(async (tx) => { + return tx + .select() + .from(BoxTable) + .where(and(eq(BoxTable.id, id), isNull(BoxTable.timeDeleted))) + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + }); + + export const listByUser = fn(Info.shape.userId, async (userId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(BoxTable) + .where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted))) + .orderBy(BoxTable.timeCreated) + .then((rows) => rows.map(serialize)); + }); + }); + + export const listByMachine = fn(Info.shape.machineId, async (machineId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(BoxTable) + .where(and(eq(BoxTable.machineId, machineId), isNull(BoxTable.timeDeleted))) + .orderBy(BoxTable.timeCreated) + .then((rows) => rows.map(serialize)); + }); + }); + + /** + * Record what `neslet` says a box is doing. + * + * The stop reason is cleared on any state that is not `stopped`, so a box + * that ran, faulted, and was started again does not keep explaining a + * failure it has since recovered from. + */ + export const setState = fn( + Info.pick({ id: true, state: true, stopReason: true, stopClean: true }), + async (input) => { + const stopped = input.state === 'stopped'; + return Database.use(async (tx) => { + return tx + .update(BoxTable) + .set({ + state: input.state, + stopReason: stopped ? (input.stopReason ?? null) : null, + stopClean: stopped ? (input.stopClean ?? null) : null + }) + .where(and(eq(BoxTable.id, input.id), isNull(BoxTable.timeDeleted))) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + } + ); + + export const rename = fn(Info.pick({ id: true, userId: true, label: true }), async (input) => { + return Database.use(async (tx) => { + return tx + .update(BoxTable) + .set({ label: input.label }) + .where( + and( + eq(BoxTable.id, input.id), + // Owner-scoped in the query, so somebody else's box is a miss + // rather than a permission check that could be forgotten. + eq(BoxTable.userId, input.userId), + isNull(BoxTable.timeDeleted) + ) + ) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + }); + + export const remove = fn(Info.shape.id, async (id) => { + await Database.use(async (tx) => { + await tx.update(BoxTable).set({ timeDeleted: sql`now()` }).where(eq(BoxTable.id, id)); + }); + }); + + export function serialize(input: typeof BoxTable.$inferSelect): z.infer { + return { + id: input.id, + userId: input.userId, + machineId: input.machineId, + label: input.label, + tier: input.tier as Info['tier'], + state: input.state as Info['state'], + stopReason: input.stopReason, + stopClean: input.stopClean + }; + } +} diff --git a/packages/core/src/db/fixtures.ts b/packages/core/src/db/fixtures.ts new file mode 100644 index 00000000..41cdabca --- /dev/null +++ b/packages/core/src/db/fixtures.ts @@ -0,0 +1,76 @@ +import { Actor } from '../actor.js'; +import { Identifier } from '../id.js'; +import { Machine } from '../machine/index.js'; +import { Team } from '../team/index.js'; +import { User } from '../user/index.js'; +import { LinkedAccount } from '../user/linked-account.js'; + +/** + * Fixtures for the ownership chain, because since + * [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md) + * it is a chain rather than a set of loose rows. + * + * A box now needs a user, a team, and a machine to exist before it can, and a + * session needs a game and a linked account on top of that. Every test that + * touches either was otherwise going to build the same four rows by hand, and + * the version built by hand is the version that quietly uses a `hst_…` string + * where a real machine id belongs — which is exactly what the new foreign key + * exists to catch. + * + * Test-only. Nothing here is imported by shipping code. + */ +export namespace Fixtures { + export interface Owner { + userId: string; + teamId: string; + linkedAccountId: string; + } + + /** + * A user with a personal team and one linked Steam account. + * + * `Team.createPersonal` reads `Actor.userID`, so this runs inside + * `Actor.with` — the same wrapping the auth worker does at login. + */ + export async function owner(label: string): Promise { + const userId = Identifier.ascending('user'); + await User.create({ + id: userId, + name: label, + email: `${label}-${userId}@example.test`, + emailVerified: true, + image: null + }); + + const linkedAccountId = Identifier.ascending('linkedAccount'); + const teamId = await Actor.with( + { type: 'user', properties: { userID: userId, linkedAccountID: linkedAccountId } }, + async () => { + await LinkedAccount.create({ + id: linkedAccountId, + userId, + provider: 'steam', + // Unique per fixture: `(provider, providerAccountId)` is unique, + // so a fixed value would make the second owner in any test fail + // for a reason that has nothing to do with the test. + providerAccountId: `7656${userId.slice(-13)}`, + profile: {} + }); + return Team.ensurePersonal({ displayName: label }); + } + ); + + return { userId, teamId, linkedAccountId }; + } + + /** A registered host owned by `owner`, on their team. */ + export async function machine(o: Owner, label = 'test-box'): Promise { + const registered = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: o.userId, + teamId: o.teamId, + label + }); + return registered.id; + } +} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 61dfa914..066a283e 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -29,8 +29,22 @@ export namespace Database { } } - export function client() { - const url = Env.get().DATABASE_URL || process.env.DATABASE_URL; + /** + * One pool per connection string, kept. + * + * This used to build a fresh `postgres()` pool on **every call**, and + * {@link use} calls it twice per invocation — so a process doing real work + * accumulated pools of ten connections each, holding them for the 30 second + * idle timeout. In a Worker each request is short-lived and it never showed; + * the test suite crossed 100 connections and Postgres answered *"sorry, too + * many clients already"* in whichever file happened to run last, which + * looked like a flaky test rather than a leak. + * + * Keyed by URL rather than memoized once, because `Env.init` can point at a + * different database within one process and a cached client for the previous + * one would silently keep being used. + */ + function connect(url: string | undefined) { const c = url ? postgres(url, { idle_timeout: 30, connect_timeout: 30 }) : postgres({ @@ -45,6 +59,28 @@ export namespace Database { return drizzle({ client: c }); } + // Typed from `connect` rather than from `drizzle` directly: spelling it + // `ReturnType` widens the schema parameter to its default, + // which makes `Transaction` and the plain client incompatible halves of + // `TxOrDb` and breaks every caller. + type Client = ReturnType; + + const clients = new Map(); + + export function client(): Client { + const url = Env.get().DATABASE_URL || process.env.DATABASE_URL; + const key = url ?? 'local:nestri'; + + const cached = clients.get(key); + if (cached) { + return cached; + } + + const db = connect(url); + clients.set(key, db); + return db; + } + export type Transaction = PgTransaction< PostgresJsQueryResultHKT, Record, @@ -65,12 +101,14 @@ export namespace Database { } catch (err) { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = []; + // One client, used for both. These were two separate `client()` + // calls, so the handle in the context was not the handle the + // callback ran on — harmless by luck, since neither was a real + // transaction, and twice the pools either way. + const db = client(); const result = await TransactionContext.provide( - { - effects, - tx: client() - }, - () => callback(client()) + { effects, tx: db }, + () => callback(db) ); await Promise.all(effects.map((x) => x())); return result; diff --git a/packages/core/src/db/test.ts b/packages/core/src/db/test.ts index 52c7df89..3baf8932 100644 --- a/packages/core/src/db/test.ts +++ b/packages/core/src/db/test.ts @@ -18,5 +18,13 @@ export function testDb() { 'TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestri' ); } - return postgres(url, { idle_timeout: 30, connect_timeout: 30 }); + // A small pool per test file, deliberately. + // + // `postgres` defaults to ten connections, and every test file that calls + // this opens its own pool alongside the one `Database.use` opens — so at a + // dozen files the suite asks for more connections than Postgres will give + // and fails with *"sorry, too many clients already"*, in whichever file + // happens to run last. Two is plenty: these are sequential fixtures and + // assertions, not a load test. + return postgres(url, { max: 2, idle_timeout: 5, connect_timeout: 30 }); } diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index ae237a40..5dafeed6 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -123,11 +123,34 @@ export namespace Examples { export const Machine = { id: Id('machine'), ownerUserId: Id('user'), - teamId: null, + teamId: Id('team'), label: 'living-room-box', lastSeen: '2026-07-28T12:00:00.000Z' }; + export const Box = { + id: Id('box'), + userId: Id('user'), + machineId: Id('machine'), + label: 'living room', + tier: 'sm' as const, + state: 'created' as const, + stopReason: null, + stopClean: null + }; + + export const Session = { + id: Id('session'), + boxId: Id('box'), + gameId: Id('game'), + linkedAccountId: Id('linkedAccount'), + state: 'live' as const, + ticket: 'nodeaaqf…', + timeStarted: '2026-07-28T12:00:00.000Z', + timeStopped: null, + errorMessage: null + }; + export const GameDownload = { id: Id('gameDownload'), hostId: Id('machine'), diff --git a/packages/core/src/game/download.sql.ts b/packages/core/src/game/download.sql.ts index bfcc245d..a2d98e15 100644 --- a/packages/core/src/game/download.sql.ts +++ b/packages/core/src/game/download.sql.ts @@ -1,6 +1,7 @@ import { bigint, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps, ulid, utc } from '../db/types.js'; +import { MachineTable } from '../machine/machine.sql.js'; import { GameTable } from './game.sql.js'; export const GameDownloadStatus = pgEnum('game_download_status', [ @@ -16,7 +17,12 @@ export const GameDownloadTable = pgTable( { ...id, ...timestamps, - hostId: text('host_id').notNull(), + // A foreign key since 0048. It was a bare `text` column — the one place a + // host was referred to by a string nothing checked — so a typo produced + // a download row belonging to a machine that had never existed. + hostId: ulid('host_id') + .notNull() + .references(() => MachineTable.id, { onDelete: 'cascade' }), gameId: ulid('game_id') .notNull() .references(() => GameTable.id, { onDelete: 'cascade' }), diff --git a/packages/core/src/game/download.test.ts b/packages/core/src/game/download.test.ts index 2fea5081..12efa1f0 100644 --- a/packages/core/src/game/download.test.ts +++ b/packages/core/src/game/download.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { Fixtures } from '../db/fixtures.js'; import { testDb } from '../db/test.js'; import { Game } from '../game/index.js'; import { Identifier } from '../id.js'; @@ -7,10 +8,19 @@ import { GameDownload } from './download.js'; const sql = testDb(); -const HOST_A = 'hst_aaaaaaaaaaaaaaaaaaaaaaaaa'; -const HOST_B = 'hst_bbbbbbbbbbbbbbbbbbbbbbbbb'; +/** + * Real registered hosts, not `hst_…` strings. + * + * These were literals until 0048 made `host_id` a foreign key. The old values + * were the bug the key exists to prevent — a download row attributed to a host + * that had never registered — so the test that used them was asserting against + * a state the database now refuses. + */ +let HOST_A: string; +let HOST_B: string; const createdGameIds: string[] = []; +const createdUserIds: string[] = []; const gameIdByApp = new Map(); async function ensureGame(steamAppId: number): Promise { @@ -29,6 +39,11 @@ async function ensureGame(steamAppId: number): Promise { } beforeAll(async () => { + const owner = await Fixtures.owner('download-host-owner'); + createdUserIds.push(owner.userId); + HOST_A = await Fixtures.machine(owner, 'download-host-a'); + HOST_B = await Fixtures.machine(owner, 'download-host-b'); + await ensureGame(4400); await ensureGame(4401); await ensureGame(4402); @@ -40,6 +55,11 @@ afterAll(async () => { await sql`delete from "game" where id in ${sql(createdGameIds)}`; createdGameIds.length = 0; } + if (createdUserIds.length > 0) { + // And the user cascades to the machines those rows pointed at. + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + createdUserIds.length = 0; + } }); describe('GameDownload', () => { diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index f5896209..d52b5771 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -12,6 +12,8 @@ export namespace Identifier { userFingerprint: 'ufp', pairingCode: 'pai', machine: 'mch', + box: 'box', + session: 'ses', accessToken: 'pat', game: 'gam', userLibrary: 'ulb', diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 23a161f9..dcf2af17 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -32,8 +32,9 @@ export namespace Machine { description: 'The user who registered this machine', example: Examples.Machine.ownerUserId }), - teamId: z.string().optional().nullable().meta({ - description: 'The team this machine belongs to, when registered inside one', + teamId: z.string().meta({ + description: + 'The team that owns this hardware. Always set — every user has a personal team', example: Examples.Machine.teamId }), label: z.string().meta({ @@ -89,7 +90,7 @@ export namespace Machine { await tx.insert(MachineTable).values({ id: input.id, ownerUserId: input.ownerUserId, - teamId: input.teamId ?? null, + teamId: input.teamId, label: input.label, secretHash: await hashSecret(secret), lastSeen: null @@ -130,7 +131,13 @@ export namespace Machine { ); /** - * Move a box into a team, or back out of one with `teamId: null`. + * Move a host to a different team. + * + * There is no "out of a team" any more: `teamId` is notNull since + * [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md), + * so a host always belongs to exactly one, and the single-operator case is a + * team of one rather than a null. What used to be *unscope* is now *move to + * my personal team*, which the caller names explicitly. * * Scoped to the owner in the query itself, so a machine belonging to * someone else is a miss rather than a permission check that could be @@ -147,7 +154,7 @@ export namespace Machine { return Database.use(async (tx) => { return tx .update(MachineTable) - .set({ teamId: input.teamId ?? null }) + .set({ teamId: input.teamId }) .where( and( eq(MachineTable.id, input.id), diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index 09114d58..184f0153 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -1,6 +1,7 @@ import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps, ulid, utc } from '../db/types.js'; +import { TeamTable } from '../team/team.sql.js'; import { UserTable } from '../user/user.sql.js'; /** @@ -20,10 +21,13 @@ export const MachineTable = pgTable( ownerUserId: ulid('owner_user_id') .notNull() .references(() => UserTable.id, { onDelete: 'cascade' }), - // Set only when the box was registered by someone acting inside a team. - // A personal box has no team, and requiring one would make registering - // impossible for the single-operator case that self-hosting is. - teamId: ulid('team_id'), + // Every user gets a personal team at signup, so there is always one to + // point at and the single-operator case is a team of one rather than a + // special case in every query. This was nullable until 0048, which cost + // a `teamId ?? ownerUserId` branch at each call site instead. + teamId: ulid('team_id') + .notNull() + .references(() => TeamTable.id, { onDelete: 'restrict' }), label: text('label').notNull(), // The secret itself is returned exactly once, at registration, and never // stored: a leaked database must not yield working box credentials. diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts new file mode 100644 index 00000000..98e178a6 --- /dev/null +++ b/packages/core/src/session/index.ts @@ -0,0 +1,204 @@ +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { Examples } from '../examples.js'; +import { fn } from '../fn.js'; +import { SessionState, SessionTable } from './session.sql.js'; + +/** + * One run of one box, and the unit that gets billed. + * + * A row appears at `POST /session`, before anything has been placed — so the + * row *is* the job the control plane fulfils, and `requested` is a real state + * rather than a placeholder. The ticket arrives later and changes as addresses + * are discovered, which is why a client polls this rather than being handed a + * value once. + */ +export namespace Session { + export const Info = z + .object({ + id: z.string().meta({ + description: 'Unique identifier for this run', + example: Examples.Session.id + }), + boxId: z.string().meta({ + description: 'The box being run', + example: Examples.Session.boxId + }), + gameId: z.string().meta({ + description: 'The game this run launched', + example: Examples.Session.gameId + }), + linkedAccountId: z.string().meta({ + description: 'Which linked Steam account is playing', + example: Examples.Session.linkedAccountId + }), + state: z.enum(SessionState.enumValues).meta({ + description: 'Where this run is. Only `live` costs money', + example: Examples.Session.state + }), + ticket: z.string().nullable().optional().meta({ + description: 'Current iroh connect ticket, or null before neshub mints one', + example: Examples.Session.ticket + }), + timeStarted: z.string().nullable().optional().meta({ + description: 'When the box actually started, not when the row appeared', + example: Examples.Session.timeStarted + }), + timeStopped: z.string().nullable().optional().meta({ + description: 'When this run ended', + example: Examples.Session.timeStopped + }), + errorMessage: z.string().nullable().optional().meta({ + description: 'Why it failed, when it did', + example: Examples.Session.errorMessage + }) + }) + .meta({ + ref: 'Session', + description: 'One live run of one box by one Steam account, and the billing unit', + example: Examples.Session + }); + + export type Info = z.infer; + + export const create = fn( + Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }), + async (input) => { + return Database.use(async (tx) => { + return tx + .insert(SessionTable) + .values({ + id: input.id, + boxId: input.boxId, + gameId: input.gameId, + linkedAccountId: input.linkedAccountId + }) + .returning() + .then((rows) => serialize(rows[0]!)); + }); + } + ); + + export const fromID = fn(Info.shape.id, async (id) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SessionTable) + .where(and(eq(SessionTable.id, id), isNull(SessionTable.timeDeleted))) + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + }); + + /** + * The run currently occupying a box, if any. + * + * Newest first and limited to one: a box has at most one live session by + * construction, and if that ever stops being true this is the query that + * should start refusing rather than picking a winner silently. + */ + export const activeForBox = fn(Info.shape.boxId, async (boxId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SessionTable) + .where( + and( + eq(SessionTable.boxId, boxId), + isNull(SessionTable.timeDeleted), + isNull(SessionTable.timeStopped) + ) + ) + .orderBy(desc(SessionTable.timeCreated)) + .limit(1) + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + }); + + export const listByBox = fn(Info.shape.boxId, async (boxId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SessionTable) + .where(and(eq(SessionTable.boxId, boxId), isNull(SessionTable.timeDeleted))) + .orderBy(desc(SessionTable.timeCreated)) + .then((rows) => rows.map(serialize)); + }); + }); + + /** + * Publish the current ticket. + * + * Overwrites, deliberately: the vsock contract describes the ticket as *"a + * stream, not one value"*, so a later ticket for the same session is a + * better address for the same thing and not a second session. + */ + export const setTicket = fn(Info.pick({ id: true, ticket: true }), async (input) => { + return Database.use(async (tx) => { + return tx + .update(SessionTable) + .set({ ticket: input.ticket ?? null }) + .where(and(eq(SessionTable.id, input.id), isNull(SessionTable.timeDeleted))) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + }); + + /** + * Move a run along. + * + * `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both + * only if unset — so a duplicate report does not extend a session someone is + * billed for, and metering can trust the pair. + */ + export const setState = fn( + Info.pick({ id: true, state: true, errorMessage: true }), + async (input) => { + const now = sql`now()`; + return Database.use(async (tx) => { + return tx + .update(SessionTable) + .set({ + state: input.state, + errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null, + ...(input.state === 'live' + ? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` } + : {}), + ...(input.state === 'ended' || input.state === 'failed' + ? { timeStopped: sql`coalesce(${SessionTable.timeStopped}, ${now})` } + : {}) + }) + .where(and(eq(SessionTable.id, input.id), isNull(SessionTable.timeDeleted))) + .returning() + .then((rows) => { + const row = rows.at(0); + return row ? serialize(row) : null; + }); + }); + } + ); + + export function serialize(input: typeof SessionTable.$inferSelect): z.infer { + return { + id: input.id, + boxId: input.boxId, + gameId: input.gameId, + linkedAccountId: input.linkedAccountId, + state: input.state as Info['state'], + ticket: input.ticket, + timeStarted: input.timeStarted?.toISOString() ?? null, + timeStopped: input.timeStopped?.toISOString() ?? null, + errorMessage: input.errorMessage + }; + } +} diff --git a/packages/core/src/session/session.sql.ts b/packages/core/src/session/session.sql.ts new file mode 100644 index 00000000..c45b6431 --- /dev/null +++ b/packages/core/src/session/session.sql.ts @@ -0,0 +1,74 @@ +import { index, pgEnum, pgTable, text } from 'drizzle-orm/pg-core'; + +import { id, timestamps, ulid, utc } from '../db/types.js'; +import { BoxTable } from '../box/box.sql.js'; +import { GameTable } from '../game/game.sql.js'; +import { LinkedAccountTable } from '../user/linked-account.sql.js'; + +/** + * Where a session is in its one and only run. + * + * `requested` is written by `POST /session` before anything has been placed, + * which is what makes the row the job: the control plane picks a machine and + * `neslet` takes it from here. `live` is the only state that costs money. + */ +export const SessionState = pgEnum('session_state', [ + 'requested', + 'starting', + 'live', + 'ended', + 'failed' +]); + +/** + * One live run of one box, and the thing that gets billed. + * + * Separate from `box` for two reasons + * ([0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)): + * a box is a durable thing somebody owns while a session is what costs money + * and what [`limits.md`](../../../../.nestri/contracts/limits.md) burns + * session-hours against — and because the connect ticket **changes after bind + * as addresses are discovered.** The vsock contract calls it *"a stream, not + * one value"*, so `ticket` is a column that gets rewritten in place while the + * session is starting, and a client polls it rather than receiving it once. + */ +export const SessionTable = pgTable( + 'session', + { + ...id, + ...timestamps, + boxId: ulid('box_id') + .notNull() + .references(() => BoxTable.id, { onDelete: 'cascade' }), + gameId: ulid('game_id') + .notNull() + .references(() => GameTable.id, { onDelete: 'restrict' }), + // Which Steam account this run is playing as. A user may have up to four + // linked, and *which one* is the question the "who's playing?" screen + // asks — so it belongs on the session and not on the box. + // + // `restrict`, because unlinking a Steam account must not erase the + // billing history of what it played. + linkedAccountId: ulid('linked_account_id') + .notNull() + .references(() => LinkedAccountTable.id, { onDelete: 'restrict' }), + state: SessionState('state').notNull().default('requested'), + /** + * The current iroh connect ticket, or null before `neshub` has minted + * one. Rewritten as addresses are discovered; never append-only. + */ + ticket: text('ticket'), + /** Null until the box actually starts, which is not when the row appears. */ + timeStarted: utc('time_started'), + timeStopped: utc('time_stopped'), + /** Why it ended badly, when it did. */ + errorMessage: text('error_message') + }, + (t) => [ + index('session_box_idx').on(t.boxId), + index('session_state_idx').on(t.state), + // Metering reads "sessions in this window"; per 0048 this table is what + // billing sums, so the time index is not speculative. + index('session_started_idx').on(t.timeStarted) + ] +); diff --git a/packages/core/src/session/session.test.ts b/packages/core/src/session/session.test.ts new file mode 100644 index 00000000..ae154ea0 --- /dev/null +++ b/packages/core/src/session/session.test.ts @@ -0,0 +1,175 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { Box } from '../box/index.js'; +import { Fixtures } from '../db/fixtures.js'; +import { testDb } from '../db/test.js'; +import { Game } from '../game/index.js'; +import { Identifier } from '../id.js'; +import { Session } from './index.js'; + +const sql = testDb(); + +const createdUserIds: string[] = []; +const createdGameIds: string[] = []; + +async function newOwner(label: string) { + const o = await Fixtures.owner(label); + createdUserIds.push(o.userId); + return o; +} + +async function newGame(steamAppId: number): Promise { + const [row] = await Game.upsert({ + id: Identifier.ascending('game'), + steamAppId, + slug: `session-test-${steamAppId}`, + name: `Session Test ${steamAppId}` + }); + if (!row) throw new Error('expected a game row'); + createdGameIds.push(row.id); + return row.id; +} + +/** A user, a team, a machine, a box and a game — everything a session needs. */ +async function scene(label: string, steamAppId: number) { + const owner = await newOwner(label); + const machineId = await Fixtures.machine(owner); + const box = await Box.create({ + id: Identifier.ascending('box'), + userId: owner.userId, + machineId, + label, + tier: 'sm' + }); + return { owner, box, gameId: await newGame(steamAppId) }; +} + +afterAll(async () => { + if (createdUserIds.length > 0) { + // session cascades from box; box has to precede the machine, which + // cascades from the user. + await sql`delete from "box" where user_id in ${sql(createdUserIds)}`; + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + createdUserIds.length = 0; + } + if (createdGameIds.length > 0) { + await sql`delete from "game" where id in ${sql(createdGameIds)}`; + createdGameIds.length = 0; + } +}); + +describe('Session', () => { + test('a session starts requested, with no ticket and no times', async () => { + const { owner, box, gameId } = await scene('ses-defaults', 5400); + + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + expect(session.state).toBe('requested'); + expect(session.ticket).toBeNull(); + expect(session.timeStarted).toBeNull(); + expect(session.timeStopped).toBeNull(); + }); + + test('the ticket is a stream: a later one replaces the first', async () => { + const { owner, box, gameId } = await scene('ses-ticket', 5401); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + expect((await Session.setTicket({ id: session.id, ticket: 'ticket-one' }))?.ticket).toBe( + 'ticket-one' + ); + // The vsock contract calls the ticket "a stream, not one value" — a second + // ticket is a better address for the same session, not a new session. + expect((await Session.setTicket({ id: session.id, ticket: 'ticket-two' }))?.ticket).toBe( + 'ticket-two' + ); + expect(await Session.listByBox(box.id)).toHaveLength(1); + }); + + test('going live stamps a start time, and a repeat report does not move it', async () => { + const { owner, box, gameId } = await scene('ses-live', 5402); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + const live = await Session.setState({ id: session.id, state: 'live', errorMessage: null }); + expect(live?.state).toBe('live'); + expect(live?.timeStarted).not.toBeNull(); + + // This is the billing property: a duplicate `live` must not extend a + // session somebody is charged for. + const again = await Session.setState({ id: session.id, state: 'live', errorMessage: null }); + expect(again?.timeStarted).toBe(live!.timeStarted); + }); + + test('ending stamps a stop time once, and failing records why', async () => { + const { owner, box, gameId } = await scene('ses-end', 5403); + const session = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + await Session.setState({ id: session.id, state: 'live', errorMessage: null }); + + const failed = await Session.setState({ + id: session.id, + state: 'failed', + errorMessage: 'steam guard timed out' + }); + expect(failed?.state).toBe('failed'); + expect(failed?.errorMessage).toBe('steam guard timed out'); + expect(failed?.timeStopped).not.toBeNull(); + + const ended = await Session.setState({ id: session.id, state: 'ended', errorMessage: null }); + expect(ended?.timeStopped).toBe(failed!.timeStopped); + // A state that is not `failed` carries no explanation. + expect(ended?.errorMessage).toBeNull(); + }); + + test('the active session is the one that has not stopped', async () => { + const { owner, box, gameId } = await scene('ses-active', 5404); + const first = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + await Session.setState({ id: first.id, state: 'ended', errorMessage: null }); + + expect(await Session.activeForBox(box.id)).toBeNull(); + + const second = await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + expect((await Session.activeForBox(box.id))?.id).toBe(second.id); + }); + + test('deleting a box takes its sessions with it', async () => { + const { owner, box, gameId } = await scene('ses-cascade', 5405); + await Session.create({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId, + linkedAccountId: owner.linkedAccountId + }); + + await sql`delete from "box" where id = ${box.id}`; + expect(await Session.listByBox(box.id)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 021e7e4a..585390a3 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -112,6 +112,44 @@ export namespace Team { }); }); + /** + * The team a user owns by virtue of existing. + * + * Defined as the oldest team they own, because {@link createPersonal} is the + * only thing that mints a team at signup — so the first one is the personal + * one and any later ones were made deliberately. This is a convention, not a + * column: adding an `isPersonal` flag would let the two disagree, and there + * is nothing yet that needs them to. + */ + export const personalFor = fn(Info.shape.ownerId, async (ownerId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(TeamTable) + .where(and(eq(TeamTable.ownerId, ownerId), isNull(TeamTable.timeDeleted))) + .orderBy(TeamTable.timeCreated) + .limit(1) + .then((rows) => rows.at(0) ?? null); + }); + }); + + /** + * The personal team, made if it is not there. + * + * Every user has needed one since [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md) + * made `machine.teamId` notNull, so signup calls this and so does anything + * that needs somewhere to put a host. Idempotent, because it runs on every + * login rather than only on the first one — a user created before 0048 has + * no team and gets one the next time they appear. + */ + export const ensurePersonal = fn(z.object({ displayName: z.string() }), async (input) => { + const existing = await personalFor(Actor.userID); + if (existing) { + return existing.id; + } + return createPersonal({ displayName: input.displayName }); + }); + export const createPersonal = fn(z.object({ displayName: z.string() }), async (input) => { const baseSlug = input.displayName .toLowerCase() diff --git a/packages/core/src/team/team.test.ts b/packages/core/src/team/team.test.ts new file mode 100644 index 00000000..68ca5c4f --- /dev/null +++ b/packages/core/src/team/team.test.ts @@ -0,0 +1,82 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { Actor } from '../actor.js'; +import { Fixtures } from '../db/fixtures.js'; +import { testDb } from '../db/test.js'; +import { Identifier } from '../id.js'; +import { Member } from './member.js'; +import { Team } from './index.js'; + +const sql = testDb(); + +const createdUserIds: string[] = []; + +async function newOwner(label: string) { + const o = await Fixtures.owner(label); + createdUserIds.push(o.userId); + return o; +} + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + createdUserIds.length = 0; + } +}); + +describe('Team.ensurePersonal', () => { + test('a new user gets exactly one team, and owns it', async () => { + const owner = await newOwner('team-first'); + + const team = await Team.personalFor(owner.userId); + expect(team?.id).toBe(owner.teamId); + expect(team?.ownerId).toBe(owner.userId); + + const memberships = await Member.listByUser(owner.userId); + expect(memberships).toHaveLength(1); + expect(memberships[0]!.role).toBe('owner'); + }); + + test('it is idempotent, because it runs on every login', async () => { + const owner = await newOwner('team-idempotent'); + + // The auth worker calls this each time somebody signs in, not only when + // the user is created — that is what backfills accounts made before the + // call existed. A second call must not mint a second team. + const again = await Actor.with( + { type: 'user', properties: { userID: owner.userId, linkedAccountID: owner.linkedAccountId } }, + () => Team.ensurePersonal({ displayName: 'team-idempotent' }) + ); + + expect(again).toBe(owner.teamId); + const rows = await sql`select count(*)::int as n from team where owner_id = ${owner.userId}`; + expect(rows[0]!.n).toBe(1); + }); + + test('a user who predates the personal team gets one on next login', async () => { + // The legacy row the 0007 migration and this call between them repair: a + // user created by Steam sign-in before `ensurePersonal` was ever wired up. + const userId = Identifier.ascending('user'); + createdUserIds.push(userId); + await sql`insert into "user" (id, name, email) values (${userId}, ${'legacy'}, ${`legacy-${userId}@example.test`})`; + + expect(await Team.personalFor(userId)).toBeNull(); + + const teamId = await Actor.with( + { type: 'user', properties: { userID: userId, linkedAccountID: 'lac_unused' } }, + () => Team.ensurePersonal({ displayName: 'legacy' }) + ); + + expect(teamId).toBeTruthy(); + expect((await Team.personalFor(userId))?.id).toBe(teamId); + }); + + test('two users with the same display name get distinct slugs', async () => { + const a = await newOwner('same-name'); + const b = await newOwner('same-name'); + + const teamA = await Team.personalFor(a.userId); + const teamB = await Team.personalFor(b.userId); + expect(teamA!.slug).not.toBe(teamB!.slug); + }); +});