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,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)]
);

View 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 elses 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']);
});
});

View 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 neslets 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 teams 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
};
}
}