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:
Wanjohi
2026-09-03 21:39:27 +03:00
parent 1bfdfcf3cf
commit 6c1d407985
23 changed files with 3681 additions and 44 deletions

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}