mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
docs: this repo is public, so say what things are, not who decided them
Comments and served API descriptions here had grown references that only make sense to someone with our internal notes: relative paths that escape this tree, filenames and titles of documents nobody outside can open, quoted prose from them, and the name of a component that has no public surface — once in an OpenAPI description, which is published output rather than source. None of it was load-bearing. Every case restates as what the code actually requires, and every rewrite came out shorter: "in the words the host agent reports" for a component name, "republished as addresses are discovered" for a quoted phrase, "a size tier sets vCPU, RAM and the output geometry" for a sentence that had been carrying a path. Internal reasoning is now cited exactly one way, ref(d-NNNN) in a source comment, with the rule that the sentence must still stand if the marker is deleted. CLAUDE.md leads with it, because the previous version of this mistake was made by people who knew the repo was public and it still took ten occurrences to notice, so "be careful" is not a mechanism. Commit messages get the stricter rule and carry no references at all: a comment can be fixed by the next commit and a published message cannot be fixed at all. Git hooks now enforce both halves. The check caught a real one while being written: the CLAUDE.md table spelled out the paths it was prohibiting, which discloses them to exactly the reader it protects against. 138 tests, 0 fail.
This commit is contained in:
70
CLAUDE.md
70
CLAUDE.md
@@ -26,16 +26,65 @@ Both toolchains live at the root: `package.json` is the Bun workspace,
|
||||
| `cargo build --workspace` · `cargo test --workspace` | the Rust half |
|
||||
| `bun run deploy:sandbox` | deploy a stage |
|
||||
|
||||
## Two rules that are not style preferences
|
||||
## Three rules that are not style preferences
|
||||
|
||||
**Nothing closed may enter this repo.** Not source, not a dependency, not a
|
||||
directory that "looked convenient". Before adding a top-level directory, know
|
||||
which component it is and that the component is open. This has already been
|
||||
caught once, in a commit that was never pushed.
|
||||
### 1. THIS REPO IS PUBLIC. Write nothing that only makes sense to us.
|
||||
|
||||
**Versions are pinned once, centrally.** A Cargo member writes
|
||||
`tokio.workspace = true` and never a version; a TS package uses the root
|
||||
`catalog`. Two packages in one tree must not disagree about a dependency.
|
||||
**Read this before writing a single comment, docstring or commit message.** It
|
||||
has been violated twice, both times by someone who knew the repo was public,
|
||||
both times about ten occurrences deep before anyone noticed. Being careful is
|
||||
demonstrably not enough, so the rules below are mechanical.
|
||||
|
||||
**Never, anywhere in this repo:**
|
||||
|
||||
| ✗ | why |
|
||||
|---|---|
|
||||
| **Any relative path that escapes this tree** into an internal repo, or the filename of an internal document | A filename plus a title tells a reader exactly what to ask for |
|
||||
| A quotation from an internal document, even one phrase | Restate the requirement in this repo's own words |
|
||||
| The name of a component with no public surface | It discloses the shape of the system, which is the part deliberately kept |
|
||||
| Anything of the above in a **commit message** | History is permanent here and is deliberately never rewritten — a message cannot be fixed by a later commit |
|
||||
| Anything of the above in **published output** — an OpenAPI `description`, an error message, CLI text, a README | A docstring that becomes an API description reaches people who never open the source. Check where a string *goes*, not what file it is in |
|
||||
|
||||
**The one sanctioned exception**, and the only way to cite internal reasoning:
|
||||
|
||||
```ts
|
||||
// A size tier sets vCPU, RAM and the output geometry. ref(d-0021)
|
||||
```
|
||||
|
||||
`ref(d-NNNN)` · `todo(d-NNNN)` · `fixme(d-NNNN)`, in **source comments only**.
|
||||
Note `d-NNNN` and never `d/NNNN` — a slash reads as a path.
|
||||
|
||||
**The test that makes it decidable — apply it to every sentence:**
|
||||
|
||||
> Delete the marker. Does the comment still say something true and useful about
|
||||
> *this* code?
|
||||
|
||||
If yes, it belongs. If the sentence collapses without the reference, it was
|
||||
describing our topology rather than this component, and the fix is to state the
|
||||
**requirement** instead of who set it. In practice a category noun does it —
|
||||
*the caller*, *the host agent*, *an orchestrator*, *the control plane* — and the
|
||||
result is a better sentence, because it says what is needed rather than who
|
||||
happens to satisfy it. Every single case fixed so far got shorter and clearer.
|
||||
|
||||
**A name a user types is not a leak.** `nessh` appears throughout this repo on
|
||||
purpose: the product *is* `ssh nestri.io`, so hiding it would mean hiding what
|
||||
we sell. The test narrows to: does this name appear because a **user**
|
||||
encounters it, or because a **component** does?
|
||||
|
||||
If you are unsure whether a name is internal, do not guess and do not grep for
|
||||
permission — write the category noun. It is never wrong.
|
||||
|
||||
### 2. Nothing closed may enter this repo.
|
||||
|
||||
Not source, not a dependency, not a directory that "looked convenient". Before
|
||||
adding a top-level directory, know which component it is and that the component
|
||||
is open. This has already been caught once, in a commit that was never pushed.
|
||||
|
||||
### 3. Versions are pinned once, centrally.
|
||||
|
||||
A Cargo member writes `tokio.workspace = true` and never a version; a TS package
|
||||
uses the root `catalog`. Two packages in one tree must not disagree about a
|
||||
dependency.
|
||||
|
||||
## Where the detail is
|
||||
|
||||
@@ -90,3 +139,8 @@ allowed kind: a name in prose, never a dependency in code.
|
||||
Conventional commits. Explain *why* in the body — the diff already shows what.
|
||||
Comments earn their place by saying something the code cannot; a comment
|
||||
restating the line below it is noise.
|
||||
|
||||
**A commit message here is public and permanent.** No internal component names,
|
||||
no decision numbers, no `ref(d-…)` markers — those are for source comments,
|
||||
where a later commit can fix a mistake. Describe the change in this repo's own
|
||||
terms: what changed, and why it is better. See rule 1.
|
||||
|
||||
@@ -76,11 +76,11 @@ export namespace MachineApi {
|
||||
);
|
||||
}
|
||||
|
||||
// `machine.teamId` is notNull since 0048, so a team has to be
|
||||
// resolved rather than defaulted to null. The order is: what the
|
||||
// caller asked for, then the team they are acting inside, then
|
||||
// their personal team — which `ensurePersonal` makes if this is a
|
||||
// user who predates 0048 and has none.
|
||||
// A team has to be resolved rather than defaulted to null, because
|
||||
// `machine.teamId` is notNull. The order is: what the caller asked
|
||||
// for, then the team they are acting inside, then their personal
|
||||
// team — which `ensurePersonal` makes if this is an older user who
|
||||
// has none. ref(d-0048)
|
||||
const owningTeam =
|
||||
teamId ??
|
||||
(actor.type === 'member'
|
||||
@@ -120,7 +120,7 @@ export namespace MachineApi {
|
||||
tags: ['Machine'],
|
||||
summary: 'Move a box to another team',
|
||||
description:
|
||||
'Move a machine you own to a team you belong to. Hardware always belongs to exactly one team since 0048, so there is no way to unscope — name your personal team instead. This is not ownership transfer: the owner does not change.',
|
||||
'Move a machine you own to a team you belong to. Hardware always belongs to exactly one team, so there is no way to unscope — name your personal team instead. This is not ownership transfer: the owner does not change.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||
|
||||
@@ -53,10 +53,10 @@ describe('POST /machine/heartbeat', () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as any;
|
||||
// These two field names are what `neslet`'s plane.rs reads out of the
|
||||
// reply. A rename on either side produces a host that beats, parses
|
||||
// nothing, and reports success — so the names are the contract and this
|
||||
// is the test that holds them.
|
||||
// These two field names are what a host agent reads out of the reply. A
|
||||
// rename on either side produces a host that beats, parses nothing, and
|
||||
// reports success — so the names are the contract and this is the test
|
||||
// that holds them.
|
||||
expect(typeof body.data.lastSeen).toBe('string');
|
||||
expect(body.data.intervalSeconds).toBe(Machine.HEARTBEAT_SECONDS);
|
||||
expect(new Date(body.data.lastSeen).getTime()).not.toBeNaN();
|
||||
|
||||
@@ -386,10 +386,10 @@ describe('Box access', () => {
|
||||
});
|
||||
|
||||
test('teamId is required on the body, and null is no longer a value', async () => {
|
||||
// Null used to mean "make it mine alone". Since 0048 made
|
||||
// `machine.teamId` notNull there is no such state — hardware belongs to
|
||||
// exactly one team and the personal team is the one to name — so null is
|
||||
// now a validation error rather than a meaning.
|
||||
// Null used to mean "make it mine alone". Now that `machine.teamId` is
|
||||
// notNull there is no such state — hardware belongs to exactly one team
|
||||
// and the personal team is the one to name — so null is a validation
|
||||
// error rather than a meaning.
|
||||
const missing = await app.request('/machine/mch_whatever', {
|
||||
method: 'PATCH',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
|
||||
@@ -84,10 +84,10 @@ export default {
|
||||
});
|
||||
|
||||
// Every user needs a personal team, because `machine.teamId` is
|
||||
// notNull since 0048 and registering a host has nowhere to put
|
||||
// it otherwise. `packages/core/CLAUDE.md` documented this call
|
||||
// as part of the login flow and it was never actually made, so
|
||||
// no user in the database has one.
|
||||
// notNull and registering a host has nowhere to put it
|
||||
// otherwise. `packages/core/CLAUDE.md` documented this call as
|
||||
// part of the login flow and it was never actually made, so no
|
||||
// user in the database has one. ref(d-0048)
|
||||
//
|
||||
// Run on every login rather than only on creation: that is what
|
||||
// backfills the accounts made before this existed, and
|
||||
|
||||
@@ -532,7 +532,8 @@ mod tests {
|
||||
#[test]
|
||||
fn the_window_is_the_shortest_wrapping_run_holding_half() {
|
||||
// Straddling midnight is the case a non-wrapping scan gets wrong, and
|
||||
// it is exactly the evening peak 0017 is about.
|
||||
// it is exactly the evening peak the demand question is about.
|
||||
// ref(d-0017)
|
||||
let (h, n) = hist(&[(23, 6), (0, 6), (12, 1), (13, 1)]);
|
||||
assert_eq!(peak_window(&h, n), Some((23, 0)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- 0048: a box is a row, a session is the billing unit, and hardware belongs to
|
||||
-- exactly one team.
|
||||
-- A box is a row, a session is the billing unit, and hardware belongs to
|
||||
-- exactly one team. ref(d-0048)
|
||||
--
|
||||
-- Three of the five changes here touch live rows, and the generated form of
|
||||
-- this migration would have failed on all three:
|
||||
@@ -105,7 +105,7 @@ WHERE m."team_id" IS NULL;--> statement-breakpoint
|
||||
--
|
||||
-- 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
|
||||
-- itself, and the host 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
|
||||
|
||||
@@ -51,9 +51,9 @@ const Machine = z.object({
|
||||
properties: z.object({
|
||||
machineID: z.string(),
|
||||
ownerUserID: z.string(),
|
||||
// Not optional: `machine.teamId` is notNull since 0048, so a host that
|
||||
// authenticated always has a team and the branch that used to handle
|
||||
// its absence was handling a state that can no longer exist.
|
||||
// Not optional: `machine.teamId` is notNull, so a host that authenticated
|
||||
// always has a team, and the branch that used to handle its absence was
|
||||
// handling a state that can no longer exist.
|
||||
teamID: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -7,41 +7,38 @@ 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.
|
||||
* A size tier is the unit of sale and it includes output geometry, so this
|
||||
* column decides vCPU, RAM *and* the resolution the guest is told to render
|
||||
* at. It is a *request*: what gets admitted is the pair of tier and GPU model,
|
||||
* and admission does not exist yet. ref(d-0021)
|
||||
*/
|
||||
export const BoxTier = pgEnum('box_tier', ['xs', 'sm', 'md', 'lg', 'xl']);
|
||||
|
||||
/**
|
||||
* What the box is doing, in `neslet`'s own vocabulary.
|
||||
* What the box is doing, in the words the host agent reports.
|
||||
*
|
||||
* 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.
|
||||
* Deliberately the three states an agent actually reports and no more.
|
||||
* `starting` and `stopping` are the obvious additions and both are omitted,
|
||||
* because nothing would ever write them: those transitions are synchronous
|
||||
* from the agent's side, so a state nobody sets is a state that lies. A box
|
||||
* that failed is `stopped` with `stopClean` false — "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.
|
||||
* Nothing represented a box until now: `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.
|
||||
* second and not the first. ref(d-0048)
|
||||
*/
|
||||
export const BoxTable = pgTable(
|
||||
'box',
|
||||
@@ -57,13 +54,13 @@ export const BoxTable = pgTable(
|
||||
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.
|
||||
// The id is the DNS label; 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. ref(d-0019)
|
||||
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. */
|
||||
/** Why it stopped, verbatim from the agent. 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')
|
||||
|
||||
@@ -9,11 +9,10 @@ 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".
|
||||
* The box is the thing with a name and a URL; a 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".
|
||||
* ref(d-0010), ref(d-0019)
|
||||
*/
|
||||
export namespace Box {
|
||||
export const Info = z
|
||||
@@ -39,11 +38,11 @@ export namespace Box {
|
||||
example: Examples.Box.tier
|
||||
}),
|
||||
state: z.enum(BoxState.enumValues).meta({
|
||||
description: 'What the box is doing, in neslet’s vocabulary',
|
||||
description: 'What the box is doing, as last reported by its host',
|
||||
example: Examples.Box.state
|
||||
}),
|
||||
stopReason: z.string().nullable().optional().meta({
|
||||
description: 'Why it stopped, verbatim from neslet. Null if it never ran',
|
||||
description: 'Why it stopped, as reported by its host. Null if it never ran',
|
||||
example: Examples.Box.stopReason
|
||||
}),
|
||||
stopClean: z.boolean().nullable().optional().meta({
|
||||
@@ -114,7 +113,7 @@ export namespace Box {
|
||||
});
|
||||
|
||||
/**
|
||||
* Record what `neslet` says a box is doing.
|
||||
* Record what a box's host says it 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
|
||||
|
||||
@@ -6,16 +6,15 @@ 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.
|
||||
* Fixtures for the ownership chain, which is now 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
|
||||
* A box 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.
|
||||
* the version built by hand is the version that quietly uses a made-up host
|
||||
* string where a real machine id belongs — which is exactly what the foreign
|
||||
* key exists to catch. ref(d-0048)
|
||||
*
|
||||
* Test-only. Nothing here is imported by shipping code.
|
||||
*/
|
||||
|
||||
@@ -17,9 +17,9 @@ export const GameDownloadTable = pgTable(
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
// A foreign key since 0048. It was a bare `text` column — the one place a
|
||||
// host was referred to by a string nothing checked — so a typo produced
|
||||
// a download row belonging to a machine that had never existed.
|
||||
// A foreign key. It was a bare `text` column — the one place a host was
|
||||
// referred to by a string nothing checked — so a typo produced a download
|
||||
// row belonging to a machine that had never existed. ref(d-0048)
|
||||
hostId: ulid('host_id')
|
||||
.notNull()
|
||||
.references(() => MachineTable.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -9,9 +9,9 @@ import { GameDownload } from './download.js';
|
||||
const sql = testDb();
|
||||
|
||||
/**
|
||||
* Real registered hosts, not `hst_…` strings.
|
||||
* Real registered hosts, not made-up strings.
|
||||
*
|
||||
* These were literals until 0048 made `host_id` a foreign key. The old values
|
||||
* These were literals until `host_id` became a foreign key. The old values
|
||||
* were the bug the key exists to prevent — a download row attributed to a host
|
||||
* that had never registered — so the test that used them was asserting against
|
||||
* a state the database now refuses.
|
||||
|
||||
@@ -133,11 +133,10 @@ export namespace Machine {
|
||||
/**
|
||||
* Move a host to a different team.
|
||||
*
|
||||
* There is no "out of a team" any more: `teamId` is notNull since
|
||||
* [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md),
|
||||
* so a host always belongs to exactly one, and the single-operator case is a
|
||||
* team of one rather than a null. What used to be *unscope* is now *move to
|
||||
* my personal team*, which the caller names explicitly.
|
||||
* There is no "out of a team" any more: `teamId` is notNull, so a host always
|
||||
* belongs to exactly one and the single-operator case is a team of one rather
|
||||
* than a null. What used to be *unscope* is now *move to my personal team*,
|
||||
* which the caller names explicitly. ref(d-0048)
|
||||
*
|
||||
* Scoped to the owner in the query itself, so a machine belonging to
|
||||
* someone else is a miss rather than a permission check that could be
|
||||
|
||||
@@ -23,8 +23,8 @@ export const MachineTable = pgTable(
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
// Every user gets a personal team at signup, so there is always one to
|
||||
// point at and the single-operator case is a team of one rather than a
|
||||
// special case in every query. This was nullable until 0048, which cost
|
||||
// a `teamId ?? ownerUserId` branch at each call site instead.
|
||||
// special case in every query. This was nullable, which cost a
|
||||
// `teamId ?? ownerUserId` branch at each call site instead. ref(d-0048)
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||
|
||||
@@ -62,8 +62,8 @@ describe('Machine registration', () => {
|
||||
|
||||
test('a host always has a team, so registering without one is impossible', async () => {
|
||||
const owner = await newOwner('mch-team');
|
||||
// `teamId` is notNull since 0048 and required by the schema, so this is a
|
||||
// validation failure rather than a row with a null team.
|
||||
// `teamId` is notNull and required by the schema, so this is a validation
|
||||
// failure rather than a row with a null team.
|
||||
//
|
||||
// `toThrow` and not `rejects.toThrow`: `fn()` parses its input
|
||||
// synchronously, before any promise exists, so a bad argument never
|
||||
|
||||
@@ -136,9 +136,9 @@ export namespace Session {
|
||||
/**
|
||||
* 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.
|
||||
* Overwrites, deliberately: a ticket is republished as addresses are
|
||||
* discovered, so a later one 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) => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { LinkedAccountTable } from '../user/linked-account.sql.js';
|
||||
*
|
||||
* `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.
|
||||
* the agent on it takes over from there. `live` is the only state that costs
|
||||
* money.
|
||||
*/
|
||||
export const SessionState = pgEnum('session_state', [
|
||||
'requested',
|
||||
@@ -23,14 +24,12 @@ export const SessionState = pgEnum('session_state', [
|
||||
/**
|
||||
* 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.
|
||||
* Separate from `box` for two reasons. A box is a durable thing somebody owns,
|
||||
* while a session is what costs money and what quota is measured in
|
||||
* session-hours against. And the connect ticket **changes after bind as
|
||||
* addresses are discovered** — it is republished rather than issued once, so
|
||||
* `ticket` is a column rewritten in place while the session starts and a
|
||||
* client polls it instead of receiving a final value. ref(d-0048)
|
||||
*/
|
||||
export const SessionTable = pgTable(
|
||||
'session',
|
||||
@@ -43,9 +42,9 @@ export const SessionTable = pgTable(
|
||||
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.
|
||||
// Which Steam account this run is playing as. A user may link several,
|
||||
// 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.
|
||||
@@ -67,8 +66,8 @@ export const SessionTable = pgTable(
|
||||
(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.
|
||||
// Metering reads "sessions in this window", and this table is what
|
||||
// billing sums, so the time index is not speculative. ref(d-0048)
|
||||
index('session_started_idx').on(t.timeStarted)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -87,8 +87,8 @@ describe('Session', () => {
|
||||
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.
|
||||
// A ticket is republished as addresses are discovered — a second one 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'
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export namespace Team {
|
||||
* only thing that mints a team at signup — so the first one is the personal
|
||||
* one and any later ones were made deliberately. This is a convention, not a
|
||||
* column: adding an `isPersonal` flag would let the two disagree, and there
|
||||
* is nothing yet that needs them to.
|
||||
* is nothing yet that needs them to. ref(d-0048)
|
||||
*/
|
||||
export const personalFor = fn(Info.shape.ownerId, async (ownerId) => {
|
||||
return Database.use(async (tx) => {
|
||||
@@ -136,11 +136,10 @@ export namespace Team {
|
||||
/**
|
||||
* The personal team, made if it is not there.
|
||||
*
|
||||
* Every user has needed one since [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)
|
||||
* made `machine.teamId` notNull, so signup calls this and so does anything
|
||||
* that needs somewhere to put a host. Idempotent, because it runs on every
|
||||
* login rather than only on the first one — a user created before 0048 has
|
||||
* no team and gets one the next time they appear.
|
||||
* Every user has needed one since `machine.teamId` became notNull, so signup
|
||||
* calls this and so does anything that needs somewhere to put a host.
|
||||
* Idempotent, because it runs on every login rather than only on the first
|
||||
* one — an older user with no team gets one the next time they appear.
|
||||
*/
|
||||
export const ensurePersonal = fn(z.object({ displayName: z.string() }), async (input) => {
|
||||
const existing = await personalFor(Actor.userID);
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('Team.ensurePersonal', () => {
|
||||
});
|
||||
|
||||
test('a user who predates the personal team gets one on next login', async () => {
|
||||
// The legacy row the 0007 migration and this call between them repair: a
|
||||
// user created by Steam sign-in before `ensurePersonal` was ever wired up.
|
||||
// The legacy row the migration and this call between them repair: a user
|
||||
// created by Steam sign-in before `ensurePersonal` was ever wired up.
|
||||
const userId = Identifier.ascending('user');
|
||||
createdUserIds.push(userId);
|
||||
await sql`insert into "user" (id, name, email) values (${userId}, ${'legacy'}, ${`legacy-${userId}@example.test`})`;
|
||||
|
||||
Reference in New Issue
Block a user