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,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;
}
}

View File

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

View File

@@ -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 });
}