mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(core): a box is a row, a session is the billing unit
Migration 1 of 0048, and the first of the seven weeks — nothing about a live
feed works without these two tables, so it is not a cleanup during them.
box a VM someone owns: an id that is also its DNS label, an editable
label, an owning user, the machine it sits on, a tier and a state.
Owned by a person and placed on a team's hardware, which are two
different relationships, hence both userId and machineId.
session one run of one box by one linked Steam account, and what costs
money. Separate from box because the ticket changes after bind as
addresses are discovered — the vsock contract calls it "a stream,
not one value" — so it is a column a client polls, not a value it
is handed once.
Box states are neslet's own three and no more. `starting` and `stopping` are
the obvious additions and both are omitted because nothing would ever write
them; a failed box is `stopped` with stopClean false, which is how neslet
models it too.
The generated migration would have failed on live rows in three ways, so it
is hand-written and tested against a database seeded at the old schema:
- machine.team_id becomes notNull, and *every existing row is null* because
the old registration path passed null. Personal teams are backfilled for
machine owners first, reusing a team they already own rather than minting
a second, with the owner membership row repaired where missing.
- game_download.host_id becomes a foreign key. It held free-form strings,
so unattributable rows are deleted before the cast — the only destructive
statement here, and a considered loss: it is a progress report neslet
re-derives from disk.
- Team.createPersonal was written and documented in packages/core/CLAUDE.md
as part of the login flow and never actually called, so no user has a
team. ensurePersonal is idempotent and now runs on every login, which is
what backfills accounts the migration does not reach.
Verified on a seeded legacy database: three null-team machines backfilled, an
existing team reused rather than duplicated, a blank display name handled, and
both unattributable download rows dropped while the attributable one survived.
Also fixes two things this work ran into rather than caused:
- Database.client() built a new postgres pool on every call, and use()
called it twice per invocation — pools of ten connections held for a 30s
idle timeout. Invisible in a Worker where requests are short; the suite
crossed 100 connections and Postgres said "sorry, too many clients
already" in whichever file ran last, which reads as a flaky test rather
than a leak. Now one pool per connection string.
- download.test.ts asserted against `hst_…` host ids, which is exactly the
unattributable row the new foreign key exists to refuse.
There is no "no team" any more: PATCH /machine/:id took teamId null to mean
"mine alone" and now requires a team, because the personal team is the one to
name. Its test is updated to the new contract rather than deleted.
113 → 128 tests, 0 fail.
This commit is contained in:
@@ -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,7 +155,6 @@ 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
|
||||
@@ -137,7 +166,6 @@ export namespace MachineApi {
|
||||
'You are not a member of that team'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const machine = await Machine.setTeam({
|
||||
id: c.req.param('id'),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
packages/core/migrations/0007_box_session_team_notnull.sql
Normal file
130
packages/core/migrations/0007_box_session_team_notnull.sql
Normal file
@@ -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;
|
||||
2283
packages/core/migrations/meta/0007_snapshot.json
Normal file
2283
packages/core/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
72
packages/core/src/box/box.sql.ts
Normal file
72
packages/core/src/box/box.sql.ts
Normal file
@@ -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 — `<box_id>.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)]
|
||||
);
|
||||
141
packages/core/src/box/box.test.ts
Normal file
141
packages/core/src/box/box.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
});
|
||||
185
packages/core/src/box/index.ts
Normal file
185
packages/core/src/box/index.ts
Normal file
@@ -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<typeof Info>;
|
||||
|
||||
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<typeof Info> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
76
packages/core/src/db/fixtures.ts
Normal file
76
packages/core/src/db/fixtures.ts
Normal file
@@ -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<Owner> {
|
||||
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<string> {
|
||||
const registered = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: o.userId,
|
||||
teamId: o.teamId,
|
||||
label
|
||||
});
|
||||
return registered.id;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof drizzle>` 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<typeof connect>;
|
||||
|
||||
const clients = new Map<string, Client>();
|
||||
|
||||
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<string, never>,
|
||||
@@ -65,12 +101,14 @@ export namespace Database {
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
// 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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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<number, string>();
|
||||
|
||||
async function ensureGame(steamAppId: number): Promise<string> {
|
||||
@@ -29,6 +39,11 @@ async function ensureGame(steamAppId: number): Promise<string> {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export namespace Identifier {
|
||||
userFingerprint: 'ufp',
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
box: 'box',
|
||||
session: 'ses',
|
||||
accessToken: 'pat',
|
||||
game: 'gam',
|
||||
userLibrary: 'ulb',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
204
packages/core/src/session/index.ts
Normal file
204
packages/core/src/session/index.ts
Normal file
@@ -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<typeof Info>;
|
||||
|
||||
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<typeof Info> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
74
packages/core/src/session/session.sql.ts
Normal file
74
packages/core/src/session/session.sql.ts
Normal file
@@ -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)
|
||||
]
|
||||
);
|
||||
175
packages/core/src/session/session.test.ts
Normal file
175
packages/core/src/session/session.test.ts
Normal file
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
82
packages/core/src/team/team.test.ts
Normal file
82
packages/core/src/team/team.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user