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:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user