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

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

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