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 |
|
| `cargo build --workspace` · `cargo test --workspace` | the Rust half |
|
||||||
| `bun run deploy:sandbox` | deploy a stage |
|
| `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
|
### 1. THIS REPO IS PUBLIC. Write nothing that only makes sense to us.
|
||||||
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.
|
|
||||||
|
|
||||||
**Versions are pinned once, centrally.** A Cargo member writes
|
**Read this before writing a single comment, docstring or commit message.** It
|
||||||
`tokio.workspace = true` and never a version; a TS package uses the root
|
has been violated twice, both times by someone who knew the repo was public,
|
||||||
`catalog`. Two packages in one tree must not disagree about a dependency.
|
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
|
## 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.
|
Conventional commits. Explain *why* in the body — the diff already shows what.
|
||||||
Comments earn their place by saying something the code cannot; a comment
|
Comments earn their place by saying something the code cannot; a comment
|
||||||
restating the line below it is noise.
|
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
|
// A team has to be resolved rather than defaulted to null, because
|
||||||
// resolved rather than defaulted to null. The order is: what the
|
// `machine.teamId` is notNull. The order is: what the caller asked
|
||||||
// caller asked for, then the team they are acting inside, then
|
// for, then the team they are acting inside, then their personal
|
||||||
// their personal team — which `ensurePersonal` makes if this is a
|
// team — which `ensurePersonal` makes if this is an older user who
|
||||||
// user who predates 0048 and has none.
|
// has none. ref(d-0048)
|
||||||
const owningTeam =
|
const owningTeam =
|
||||||
teamId ??
|
teamId ??
|
||||||
(actor.type === 'member'
|
(actor.type === 'member'
|
||||||
@@ -120,7 +120,7 @@ export namespace MachineApi {
|
|||||||
tags: ['Machine'],
|
tags: ['Machine'],
|
||||||
summary: 'Move a box to another team',
|
summary: 'Move a box to another team',
|
||||||
description:
|
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: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ describe('POST /machine/heartbeat', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const body = (await res.json()) as any;
|
const body = (await res.json()) as any;
|
||||||
// These two field names are what `neslet`'s plane.rs reads out of the
|
// These two field names are what a host agent reads out of the reply. A
|
||||||
// reply. A rename on either side produces a host that beats, parses
|
// rename on either side produces a host that beats, parses nothing, and
|
||||||
// nothing, and reports success — so the names are the contract and this
|
// reports success — so the names are the contract and this is the test
|
||||||
// is the test that holds them.
|
// that holds them.
|
||||||
expect(typeof body.data.lastSeen).toBe('string');
|
expect(typeof body.data.lastSeen).toBe('string');
|
||||||
expect(body.data.intervalSeconds).toBe(Machine.HEARTBEAT_SECONDS);
|
expect(body.data.intervalSeconds).toBe(Machine.HEARTBEAT_SECONDS);
|
||||||
expect(new Date(body.data.lastSeen).getTime()).not.toBeNaN();
|
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 () => {
|
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
|
// Null used to mean "make it mine alone". Now that `machine.teamId` is
|
||||||
// `machine.teamId` notNull there is no such state — hardware belongs to
|
// notNull there is no such state — hardware belongs to exactly one team
|
||||||
// exactly one team and the personal team is the one to name — so null is
|
// and the personal team is the one to name — so null is a validation
|
||||||
// now a validation error rather than a meaning.
|
// error rather than a meaning.
|
||||||
const missing = await app.request('/machine/mch_whatever', {
|
const missing = await app.request('/machine/mch_whatever', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Every user needs a personal team, because `machine.teamId` is
|
// Every user needs a personal team, because `machine.teamId` is
|
||||||
// notNull since 0048 and registering a host has nowhere to put
|
// notNull and registering a host has nowhere to put it
|
||||||
// it otherwise. `packages/core/CLAUDE.md` documented this call
|
// otherwise. `packages/core/CLAUDE.md` documented this call as
|
||||||
// as part of the login flow and it was never actually made, so
|
// part of the login flow and it was never actually made, so no
|
||||||
// no user in the database has one.
|
// user in the database has one. ref(d-0048)
|
||||||
//
|
//
|
||||||
// Run on every login rather than only on creation: that is what
|
// Run on every login rather than only on creation: that is what
|
||||||
// backfills the accounts made before this existed, and
|
// backfills the accounts made before this existed, and
|
||||||
|
|||||||
@@ -532,7 +532,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_window_is_the_shortest_wrapping_run_holding_half() {
|
fn the_window_is_the_shortest_wrapping_run_holding_half() {
|
||||||
// Straddling midnight is the case a non-wrapping scan gets wrong, and
|
// 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)]);
|
let (h, n) = hist(&[(23, 6), (0, 6), (12, 1), (13, 1)]);
|
||||||
assert_eq!(peak_window(&h, n), Some((23, 0)));
|
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
|
-- A box is a row, a session is the billing unit, and hardware belongs to
|
||||||
-- exactly one team.
|
-- exactly one team. ref(d-0048)
|
||||||
--
|
--
|
||||||
-- Three of the five changes here touch live rows, and the generated form of
|
-- Three of the five changes here touch live rows, and the generated form of
|
||||||
-- this migration would have failed on all three:
|
-- 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
|
-- 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
|
-- 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
|
-- 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.
|
-- unattributable, which is exactly the bug the foreign key exists to prevent.
|
||||||
DELETE FROM "game_download" d
|
DELETE FROM "game_download" d
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ const Machine = z.object({
|
|||||||
properties: z.object({
|
properties: z.object({
|
||||||
machineID: z.string(),
|
machineID: z.string(),
|
||||||
ownerUserID: z.string(),
|
ownerUserID: z.string(),
|
||||||
// Not optional: `machine.teamId` is notNull since 0048, so a host that
|
// Not optional: `machine.teamId` is notNull, so a host that authenticated
|
||||||
// authenticated always has a team and the branch that used to handle
|
// always has a team, and the branch that used to handle its absence was
|
||||||
// its absence was handling a state that can no longer exist.
|
// handling a state that can no longer exist.
|
||||||
teamID: z.string()
|
teamID: z.string()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,41 +7,38 @@ import { UserTable } from '../user/user.sql.js';
|
|||||||
/**
|
/**
|
||||||
* The named size a box was asked for.
|
* The named size a box was asked for.
|
||||||
*
|
*
|
||||||
* A tier is the unit of sale and it includes output geometry
|
* A size tier is the unit of sale and it includes output geometry, so this
|
||||||
* ([0021](../../../../.nestri/decisions/0021-vm-size-tiers.md)), so this column
|
* column decides vCPU, RAM *and* the resolution the guest is told to render
|
||||||
* decides vCPU, RAM *and* the resolution the guest is told to render at. It is
|
* at. It is a *request*: what gets admitted is the pair of tier and GPU model,
|
||||||
* a *request*: `(tier, gpu_model)` is what admission actually acts on, and
|
* and admission does not exist yet. ref(d-0021)
|
||||||
* admission does not exist yet.
|
|
||||||
*/
|
*/
|
||||||
export const BoxTier = pgEnum('box_tier', ['xs', 'sm', 'md', 'lg', 'xl']);
|
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
|
* Deliberately the three states an agent actually reports and no more.
|
||||||
* and no more. `starting` and `stopping` are the obvious additions and both are
|
* `starting` and `stopping` are the obvious additions and both are omitted,
|
||||||
* omitted, because nothing would ever write them: the agent's transitions are
|
* because nothing would ever write them: those transitions are synchronous
|
||||||
* synchronous from its side, so a state nobody sets is a state that lies. A box
|
* from the agent's 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`
|
* that failed is `stopped` with `stopClean` false — "it is not running" and
|
||||||
* models it — "it is not running" and "it faulted forty seconds ago" are
|
* "it faulted forty seconds ago" are different facts, and the difference lives
|
||||||
* different facts, and the difference lives in the reason, not in the state.
|
* in the reason, not in the state.
|
||||||
*/
|
*/
|
||||||
export const BoxState = pgEnum('box_state', ['created', 'running', 'stopped']);
|
export const BoxState = pgEnum('box_state', ['created', 'running', 'stopped']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A VM someone owns.
|
* A VM someone owns.
|
||||||
*
|
*
|
||||||
* Nothing represented a box until now
|
* Nothing represented a box until now: `machine` is the *host*, and a guest
|
||||||
* ([0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)):
|
* had no id a URL could carry, no owner, no place, and no state anything could
|
||||||
* `machine` is the *host*, and a guest had no id a URL could carry, no owner,
|
* poll. Every screen the desktop app still needs is a view over this table.
|
||||||
* 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
|
* **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
|
* two different relationships.** Hence both `userId` and `machineId`: the
|
||||||
* person is who it belongs to and who gets billed through its sessions, 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
|
* 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(
|
export const BoxTable = pgTable(
|
||||||
'box',
|
'box',
|
||||||
@@ -57,13 +54,13 @@ export const BoxTable = pgTable(
|
|||||||
machineId: ulid('machine_id')
|
machineId: ulid('machine_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => MachineTable.id, { onDelete: 'restrict' }),
|
.references(() => MachineTable.id, { onDelete: 'restrict' }),
|
||||||
// The DNS label is the id, per 0019 — `<box_id>.nestri.link`. This is the
|
// The id is the DNS label; this is the display string a person edits, and
|
||||||
// display string a person edits, and it is deliberately not unique:
|
// it is deliberately not unique: two boxes called "living room" are the
|
||||||
// two boxes called "living room" are the owner's problem, not an error.
|
// owner's problem, not an error. ref(d-0019)
|
||||||
label: text('label').notNull(),
|
label: text('label').notNull(),
|
||||||
tier: BoxTier('tier').notNull().default('sm'),
|
tier: BoxTier('tier').notNull().default('sm'),
|
||||||
state: BoxState('state').notNull().default('created'),
|
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'),
|
stopReason: text('stop_reason'),
|
||||||
/** Whether that stop was a clean exit. Null while it has never run. */
|
/** Whether that stop was a clean exit. Null while it has never run. */
|
||||||
stopClean: boolean('stop_clean')
|
stopClean: boolean('stop_clean')
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import { BoxState, BoxTable, BoxTier } from './box.sql.js';
|
|||||||
/**
|
/**
|
||||||
* A VM someone owns.
|
* 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),
|
* The box is the thing with a name and a URL; a session is one run of it, and
|
||||||
* [0019](../../../../.nestri/decisions/0019-box-naming.md)); a
|
* the session is what costs money. Keeping them apart is what lets a box be a
|
||||||
* {@link ../session/index.ts | session} is one run of it, and the session is
|
* durable thing a person owns rather than a synonym for "currently playing".
|
||||||
* what costs money. Keeping them apart is what lets a box be a durable thing a
|
* ref(d-0010), ref(d-0019)
|
||||||
* person owns rather than a synonym for "currently playing".
|
|
||||||
*/
|
*/
|
||||||
export namespace Box {
|
export namespace Box {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -39,11 +38,11 @@ export namespace Box {
|
|||||||
example: Examples.Box.tier
|
example: Examples.Box.tier
|
||||||
}),
|
}),
|
||||||
state: z.enum(BoxState.enumValues).meta({
|
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
|
example: Examples.Box.state
|
||||||
}),
|
}),
|
||||||
stopReason: z.string().nullable().optional().meta({
|
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
|
example: Examples.Box.stopReason
|
||||||
}),
|
}),
|
||||||
stopClean: z.boolean().nullable().optional().meta({
|
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
|
* 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
|
* 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';
|
import { LinkedAccount } from '../user/linked-account.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixtures for the ownership chain, because since
|
* Fixtures for the ownership chain, which is now a chain rather than a set of
|
||||||
* [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)
|
* loose rows.
|
||||||
* 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
|
* 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
|
* 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
|
* 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
|
* the version built by hand is the version that quietly uses a made-up host
|
||||||
* where a real machine id belongs — which is exactly what the new foreign key
|
* string where a real machine id belongs — which is exactly what the foreign
|
||||||
* exists to catch.
|
* key exists to catch. ref(d-0048)
|
||||||
*
|
*
|
||||||
* Test-only. Nothing here is imported by shipping code.
|
* Test-only. Nothing here is imported by shipping code.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export const GameDownloadTable = pgTable(
|
|||||||
{
|
{
|
||||||
...id,
|
...id,
|
||||||
...timestamps,
|
...timestamps,
|
||||||
// A foreign key since 0048. It was a bare `text` column — the one place a
|
// A foreign key. It was a bare `text` column — the one place a host was
|
||||||
// host was referred to by a string nothing checked — so a typo produced
|
// referred to by a string nothing checked — so a typo produced a download
|
||||||
// a download row belonging to a machine that had never existed.
|
// row belonging to a machine that had never existed. ref(d-0048)
|
||||||
hostId: ulid('host_id')
|
hostId: ulid('host_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => MachineTable.id, { onDelete: 'cascade' }),
|
.references(() => MachineTable.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { GameDownload } from './download.js';
|
|||||||
const sql = testDb();
|
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
|
* 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
|
* that had never registered — so the test that used them was asserting against
|
||||||
* a state the database now refuses.
|
* a state the database now refuses.
|
||||||
|
|||||||
@@ -133,11 +133,10 @@ export namespace Machine {
|
|||||||
/**
|
/**
|
||||||
* Move a host to a different team.
|
* Move a host to a different team.
|
||||||
*
|
*
|
||||||
* There is no "out of a team" any more: `teamId` is notNull since
|
* There is no "out of a team" any more: `teamId` is notNull, so a host always
|
||||||
* [0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md),
|
* belongs to exactly one and the single-operator case is a team of one rather
|
||||||
* so a host always belongs to exactly one, and the single-operator case is a
|
* than a null. What used to be *unscope* is now *move to my personal team*,
|
||||||
* team of one rather than a null. What used to be *unscope* is now *move to
|
* which the caller names explicitly. ref(d-0048)
|
||||||
* my personal team*, which the caller names explicitly.
|
|
||||||
*
|
*
|
||||||
* Scoped to the owner in the query itself, so a machine belonging to
|
* 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
|
* 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' }),
|
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||||
// Every user gets a personal team at signup, so there is always one to
|
// 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
|
// 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
|
// special case in every query. This was nullable, which cost a
|
||||||
// a `teamId ?? ownerUserId` branch at each call site instead.
|
// `teamId ?? ownerUserId` branch at each call site instead. ref(d-0048)
|
||||||
teamId: ulid('team_id')
|
teamId: ulid('team_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
.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 () => {
|
test('a host always has a team, so registering without one is impossible', async () => {
|
||||||
const owner = await newOwner('mch-team');
|
const owner = await newOwner('mch-team');
|
||||||
// `teamId` is notNull since 0048 and required by the schema, so this is a
|
// `teamId` is notNull and required by the schema, so this is a validation
|
||||||
// validation failure rather than a row with a null team.
|
// failure rather than a row with a null team.
|
||||||
//
|
//
|
||||||
// `toThrow` and not `rejects.toThrow`: `fn()` parses its input
|
// `toThrow` and not `rejects.toThrow`: `fn()` parses its input
|
||||||
// synchronously, before any promise exists, so a bad argument never
|
// synchronously, before any promise exists, so a bad argument never
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ export namespace Session {
|
|||||||
/**
|
/**
|
||||||
* Publish the current ticket.
|
* Publish the current ticket.
|
||||||
*
|
*
|
||||||
* Overwrites, deliberately: the vsock contract describes the ticket as *"a
|
* Overwrites, deliberately: a ticket is republished as addresses are
|
||||||
* stream, not one value"*, so a later ticket for the same session is a
|
* discovered, so a later one for the same session is a better address for
|
||||||
* better address for the same thing and not a second session.
|
* the same thing and not a second session.
|
||||||
*/
|
*/
|
||||||
export const setTicket = fn(Info.pick({ id: true, ticket: true }), async (input) => {
|
export const setTicket = fn(Info.pick({ id: true, ticket: true }), async (input) => {
|
||||||
return Database.use(async (tx) => {
|
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,
|
* `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
|
* 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', [
|
export const SessionState = pgEnum('session_state', [
|
||||||
'requested',
|
'requested',
|
||||||
@@ -23,14 +24,12 @@ export const SessionState = pgEnum('session_state', [
|
|||||||
/**
|
/**
|
||||||
* One live run of one box, and the thing that gets billed.
|
* One live run of one box, and the thing that gets billed.
|
||||||
*
|
*
|
||||||
* Separate from `box` for two reasons
|
* Separate from `box` for two reasons. A box is a durable thing somebody owns,
|
||||||
* ([0048](../../../../.nestri/decisions/0048-email-is-the-root-identity-and-a-box-is-a-row.md)):
|
* while a session is what costs money and what quota is measured in
|
||||||
* a box is a durable thing somebody owns while a session is what costs money
|
* session-hours against. And the connect ticket **changes after bind as
|
||||||
* and what [`limits.md`](../../../../.nestri/contracts/limits.md) burns
|
* addresses are discovered** — it is republished rather than issued once, so
|
||||||
* session-hours against — and because the connect ticket **changes after bind
|
* `ticket` is a column rewritten in place while the session starts and a
|
||||||
* as addresses are discovered.** The vsock contract calls it *"a stream, not
|
* client polls it instead of receiving a final value. ref(d-0048)
|
||||||
* 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(
|
export const SessionTable = pgTable(
|
||||||
'session',
|
'session',
|
||||||
@@ -43,9 +42,9 @@ export const SessionTable = pgTable(
|
|||||||
gameId: ulid('game_id')
|
gameId: ulid('game_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => GameTable.id, { onDelete: 'restrict' }),
|
.references(() => GameTable.id, { onDelete: 'restrict' }),
|
||||||
// Which Steam account this run is playing as. A user may have up to four
|
// Which Steam account this run is playing as. A user may link several,
|
||||||
// linked, and *which one* is the question the "who's playing?" screen
|
// and *which one* is the question the "who's playing?" screen asks — so
|
||||||
// asks — so it belongs on the session and not on the box.
|
// it belongs on the session and not on the box.
|
||||||
//
|
//
|
||||||
// `restrict`, because unlinking a Steam account must not erase the
|
// `restrict`, because unlinking a Steam account must not erase the
|
||||||
// billing history of what it played.
|
// billing history of what it played.
|
||||||
@@ -67,8 +66,8 @@ export const SessionTable = pgTable(
|
|||||||
(t) => [
|
(t) => [
|
||||||
index('session_box_idx').on(t.boxId),
|
index('session_box_idx').on(t.boxId),
|
||||||
index('session_state_idx').on(t.state),
|
index('session_state_idx').on(t.state),
|
||||||
// Metering reads "sessions in this window"; per 0048 this table is what
|
// Metering reads "sessions in this window", and this table is what
|
||||||
// billing sums, so the time index is not speculative.
|
// billing sums, so the time index is not speculative. ref(d-0048)
|
||||||
index('session_started_idx').on(t.timeStarted)
|
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(
|
expect((await Session.setTicket({ id: session.id, ticket: 'ticket-one' }))?.ticket).toBe(
|
||||||
'ticket-one'
|
'ticket-one'
|
||||||
);
|
);
|
||||||
// The vsock contract calls the ticket "a stream, not one value" — a second
|
// A ticket is republished as addresses are discovered — a second one is a
|
||||||
// ticket is a better address for the same session, not a new session.
|
// better address for the same session, not a new session.
|
||||||
expect((await Session.setTicket({ id: session.id, ticket: 'ticket-two' }))?.ticket).toBe(
|
expect((await Session.setTicket({ id: session.id, ticket: 'ticket-two' }))?.ticket).toBe(
|
||||||
'ticket-two'
|
'ticket-two'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export namespace Team {
|
|||||||
* only thing that mints a team at signup — so the first one is the personal
|
* 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
|
* 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
|
* 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) => {
|
export const personalFor = fn(Info.shape.ownerId, async (ownerId) => {
|
||||||
return Database.use(async (tx) => {
|
return Database.use(async (tx) => {
|
||||||
@@ -136,11 +136,10 @@ export namespace Team {
|
|||||||
/**
|
/**
|
||||||
* The personal team, made if it is not there.
|
* 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)
|
* Every user has needed one since `machine.teamId` became notNull, so signup
|
||||||
* made `machine.teamId` notNull, so signup calls this and so does anything
|
* calls this and so does anything that needs somewhere to put a host.
|
||||||
* that needs somewhere to put a host. Idempotent, because it runs on every
|
* Idempotent, because it runs on every login rather than only on the first
|
||||||
* login rather than only on the first one — a user created before 0048 has
|
* one — an older user with no team gets one the next time they appear.
|
||||||
* no team and gets one the next time they appear.
|
|
||||||
*/
|
*/
|
||||||
export const ensurePersonal = fn(z.object({ displayName: z.string() }), async (input) => {
|
export const ensurePersonal = fn(z.object({ displayName: z.string() }), async (input) => {
|
||||||
const existing = await personalFor(Actor.userID);
|
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 () => {
|
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
|
// The legacy row the migration and this call between them repair: a user
|
||||||
// user created by Steam sign-in before `ensurePersonal` was ever wired up.
|
// created by Steam sign-in before `ensurePersonal` was ever wired up.
|
||||||
const userId = Identifier.ascending('user');
|
const userId = Identifier.ascending('user');
|
||||||
createdUserIds.push(userId);
|
createdUserIds.push(userId);
|
||||||
await sql`insert into "user" (id, name, email) values (${userId}, ${'legacy'}, ${`legacy-${userId}@example.test`})`;
|
await sql`insert into "user" (id, name, email) values (${userId}, ${'legacy'}, ${`legacy-${userId}@example.test`})`;
|
||||||
|
|||||||
Reference in New Issue
Block a user