diff --git a/packages/chord/src/next/contracts/README.md b/packages/chord/src/next/contracts/README.md index a6e2a06ba..bd79e9bc0 100644 --- a/packages/chord/src/next/contracts/README.md +++ b/packages/chord/src/next/contracts/README.md @@ -12,7 +12,7 @@ const Note = obj({ text: str() }); export const NotesV1 = remote("notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), // where an object lives; not in the fingerprint + hostAddress: ({ session }) => Session({ id: session }), // where an object lives; not in the fingerprint variants: { editor: { state: { notes: arr(Note) }, methods: { add: method(Note, str()), clear: method() } }, reader: { state: { notes: arr(Note) } }, @@ -22,17 +22,17 @@ export const Notes = evolve(NotesV1, (e) => e.args("add", obj({ text: str(), tag type NotesProvider = Implementation; // { variant: "editor"; members: { notes, add(args, context), clear(context) } } | { variant: "reader"; ... } ``` -- `remote(name, { version, params, placement?, variants })`: per-principal variants, each with `state` (schemas) and `methods` +- `remote(name, { version, params, hostAddress, variants })`: per-principal variants, each with `state` (schemas) and `methods` (`method(args?, result?)`; an omitted side is `null`, and a method whose arguments are `null` omits them: `clear(context)`). `local(name, { version, params, members })`: one flat map, where a schema is state and `method(...)` a method. -- `placement(params) => Address` (remote only, optional): the address of the host that serves the object for these +- `hostAddress(params) => Address` (remote only, required; a declaration error without it): the address of the host that serves the object for these params (`host/`'s routing table). The caller computes it and sends it with its hold, so a relay routes by the address alone. It is not part of the normal form or the fingerprint and `evolve` carries it. - `hostKind(name, params?)`: a kind of addressable host (`Server = hostKind("server")`, `Session = hostKind("session", { id: str() })`); `params` follow the params policy below. Calling it makes an `Address { kind, params, key }`, its key built like an identity key (`session|S1`); two addresses are equal when their keys are. The params are checked - where the address is routed or served (`invalid/placement`), not when it is made. No host-name strings in app code. + where the address is routed or served (`invalid/host_address`), not when it is made. No host-name strings in app code. - Schema helpers: `str`, `num`, `int`, `bool`, `nul`, `lit`, `oneOf`, `union`, `obj`, `arr`, `map`, `opt`, `bytes`, `unknown`, and `Static`. They return ordinary TypeBox schemas. - `evolve(previous, (e) => e...)`: the next version from structured edits (`state`, `addState`, `removeState`, `args`, @@ -65,7 +65,7 @@ Rules: - `checkAccepts(contract, accepts)`: validates an accepts table without calling a mapping; returns the accepted versions (version and fingerprint). -- `contractData(contract)`: the Chord-owned data behind a handle (its `placement`, its versions with their +- `contractData(contract)`: the Chord-owned data behind a handle (its `hostAddress`, its versions with their `VariantData`, `MethodData` and schema snapshots, its `ParamsData`); a local contract's members are the variant `LOCAL`. Type-level helpers: `ShapeOf`, `CurrentVariants`, `ContractShape`, `Params` and `Method`. - `currentVariants(contract)`: the current version's variants (a local contract has the one variant `LOCAL`); diff --git a/packages/chord/src/next/contracts/contract.ts b/packages/chord/src/next/contracts/contract.ts index cf113e96f..24a4fb47c 100644 --- a/packages/chord/src/next/contracts/contract.ts +++ b/packages/chord/src/next/contracts/contract.ts @@ -97,8 +97,8 @@ export type ContractData = { readonly kind: Kind; readonly name: string; readonly params: ParamsData; - /** A remote contract's placement: the address of the host serving an object for its params. */ - readonly placement: Placement | undefined; + /** A remote contract's host address: the address of the host serving an object for its params; none for local. */ + readonly hostAddress: HostAddress | undefined; /** Every version of the history, oldest first; the last is current. */ readonly versions: readonly VersionData[]; /** The kind of every member name ever declared in the history. */ @@ -107,8 +107,8 @@ export type ContractData = { export type MemberKind = "state" | "method" | "keyed"; -/** A placement as Chord calls it: with checked params. Plugin code: what it returns is checked where it is routed. */ -export type Placement = (params: ParamsOf) => Address; +/** A `hostAddress` as Chord calls it: with checked params. Plugin code: what it returns is checked where routed. */ +export type HostAddress = (params: ParamsOf) => Address; export type VersionData = { readonly version: number; diff --git a/packages/chord/src/next/contracts/declare.ts b/packages/chord/src/next/contracts/declare.ts index 2e3856c28..8bd3f5533 100644 --- a/packages/chord/src/next/contracts/declare.ts +++ b/packages/chord/src/next/contracts/declare.ts @@ -4,11 +4,11 @@ import { type Contract, createContract, + type HostAddress, type Kind, LOCAL, type MemberKind, type MethodData, - type Placement, type VariantData, } from "./contract.ts"; import { materialize } from "./fingerprint.ts"; @@ -79,7 +79,7 @@ export function remote< readonly version: V; readonly params: P & NoInfer>; /** The address of the host serving an object for these params; never part of the fingerprint. */ - readonly placement?: (params: { [F in keyof P]: Static }) => Address; + readonly hostAddress: (params: { [F in keyof P]: Static }) => Address; readonly variants: D & NoInfer>; }, ): Contract<{ @@ -90,16 +90,14 @@ export function remote< }> { // Declaration policy: JavaScript callers reach every check below. checkName(name, "A contract name"); - checkKeys(declaration, ["version", "params", "placement", "variants"], name); + checkKeys(declaration, ["version", "params", "hostAddress", "variants"], name); const variants = entriesOf(declaration.variants, `${name}.variants`); if (variants.length === 0) throw new Error(`${name}.variants: at least one variant is required`); - const { placement } = declaration; - if (placement !== undefined && typeof placement !== "function") { - throw new Error(`${name}.placement: expected a function of the params`); - } + const { hostAddress } = declaration; + if (typeof hostAddress !== "function") throw new Error(`${name}.hostAddress: expected a function of the params`); const read = variants.map(([variant, members]) => [variant, readVariant(members, `${name}.${variant}`)] as const); - // The placement gets the checked params of this contract. - return declare("remote", name, declaration, new Map(read), placement as Placement | undefined); + // The host address gets the checked params of this contract. + return declare("remote", name, declaration, new Map(read), hostAddress as HostAddress); } /** @@ -165,7 +163,7 @@ function declare( name: string, declaration: { readonly version: number; readonly params: unknown }, variants: ReadonlyMap, - placement?: Placement, + hostAddress?: HostAddress, ): C { const { version } = declaration; if (!Number.isSafeInteger(version) || version < 1) throw new Error(`${name}.version: expected a positive integer`); @@ -173,7 +171,7 @@ function declare( checkMembers(variants, kinds, (variant, member, memberKind) => memberPath(name, kind, variant, member, memberKind)); const params = readParams(entriesOf(declaration.params, `${name}.params`), `${name}.params`); const versions = [materialize(kind, params, version, variants, [])]; - return createContract({ kind, name, params, placement, versions, kinds }); + return createContract({ kind, name, params, hostAddress, versions, kinds }); } /** diff --git a/packages/chord/src/next/contracts/host-kind.ts b/packages/chord/src/next/contracts/host-kind.ts index 3f0e61055..b71152d55 100644 --- a/packages/chord/src/next/contracts/host-kind.ts +++ b/packages/chord/src/next/contracts/host-kind.ts @@ -1,5 +1,5 @@ // Host kinds and addresses (§3): a kind of addressable host with params like a contract's, and the address of one -// host of a kind, which a remote contract's placement computes from its params. +// host of a kind, which a remote contract's `hostAddress` computes from its params. import { checkName } from "./names.ts"; import { joinKey, type Params, type ParamsData, type RequiredFields, readParams } from "./params.ts"; diff --git a/packages/chord/src/next/data/README.md b/packages/chord/src/next/data/README.md index 1b8817496..c3caba867 100644 --- a/packages/chord/src/next/data/README.md +++ b/packages/chord/src/next/data/README.md @@ -33,7 +33,7 @@ try { - `checkParams(contract, raw, context)`: one compiled closed-object check of a get's params and their key; `identityKey(contract, key, principalKey)` (no principal key for `local`); `checkAddress(kind, params, raw, - context)`: the same check of an address's params against its host kind's (`invalid/placement`), and its key. + context)`: the same check of an address's params against its host kind's (`invalid/host_address`), and its key. - `bindProvider(contract, accepts)` once per registration, then `checkArgs(provider, method, raw, boundVersion, context)` per call. It returns `{ kind: "method", args, result? }` (current-shape arguments, and the result mapping back to the caller's version) or `{ kind: "call", args, call }` (old arguments for the provider's replacement). @@ -51,7 +51,7 @@ Rules: them. Results and state are never checked. Nothing in L1 is frozen. - A method without arguments (argument schema `null`) omits the argument in references and implementations; `null` is its internal and wire form, which `checkArgs` checks. Unknown fields fail. -- A failed check is `invalid/args`, `invalid/params` or `invalid/placement` with `details.path`, a JSON Pointer from TypeBox's first error. +- A failed check is `invalid/args`, `invalid/params` or `invalid/host_address` with `details.path`, a JSON Pointer from TypeBox's first error. TypeBox does not escape `~` and `/`, so keys containing them make the path ambiguous. - `checkArgs` refuses an unknown method with `invalid/member` and an unaccepted version or a refused method with `contract_mismatch`. It never invokes a `call` replacement. diff --git a/packages/chord/src/next/data/errors.ts b/packages/chord/src/next/data/errors.ts index 623c05fe2..acb8a7d4e 100644 --- a/packages/chord/src/next/data/errors.ts +++ b/packages/chord/src/next/data/errors.ts @@ -8,7 +8,7 @@ export const PROTOCOL_VERSION = 1; /** Every code with the reasons it may carry. `invalid` requires a reason; every other code may omit it. */ export const CHORD_ERROR_REASONS = { - invalid: ["args", "params", "member", "placement", "reentrant", "read_after_bulk", "cycle"], + invalid: ["args", "params", "member", "host_address", "reentrant", "read_after_bulk", "cycle"], contract_mismatch: ["updating"], forbidden: ["revoked"], gone: [], diff --git a/packages/chord/src/next/data/params.ts b/packages/chord/src/next/data/params.ts index 17ce090e1..be971496f 100644 --- a/packages/chord/src/next/data/params.ts +++ b/packages/chord/src/next/data/params.ts @@ -32,11 +32,11 @@ export function identityKey( } /** - * An address from a kind's name and params as a hold names it or a placement returned it: the params checked against - * the kind's with one compiled closed-object check (`invalid/placement` on failure), the key computed. + * An address from a kind's name and params as a hold names it or a `hostAddress` returned it: the params checked + * against the kind's with one compiled closed-object check (`invalid/host_address` on failure), the key computed. */ export function checkAddress(kind: string, params: ParamsData, raw: unknown, context: Context): Address { - check(validatorFor(params, context), raw, "placement", context); + check(validatorFor(params, context), raw, "host_address", context); // The check above establishes a flat record of the kind's scalar fields. const values = raw as { readonly [field: string]: string | number | boolean }; return { kind, params: values, key: addressKey(kind, params, values) }; diff --git a/packages/chord/src/next/data/validator.ts b/packages/chord/src/next/data/validator.ts index 6c3369a6b..4a16599de 100644 --- a/packages/chord/src/next/data/validator.ts +++ b/packages/chord/src/next/data/validator.ts @@ -34,7 +34,7 @@ const validators = new WeakMap(); export function check( validator: Validator, value: unknown, - reason: "args" | "params" | "placement", + reason: "args" | "params" | "host_address", context: Context, ): void { context.telemetry.metrics?.counter("chord.data.validations").add(1); @@ -48,7 +48,7 @@ export function check( function failedCheck( schema: TSchema, value: unknown, - reason: "args" | "params" | "placement", + reason: "args" | "params" | "host_address", context: Context, ): ChordError { context.telemetry.metrics?.counter("chord.data.walks").add(1); diff --git a/packages/chord/src/next/host/README.md b/packages/chord/src/next/host/README.md index da13258bc..b5c9175fa 100644 --- a/packages/chord/src/next/host/README.md +++ b/packages/chord/src/next/host/README.md @@ -79,7 +79,7 @@ Addressable hosts and routes (slice 4a): a server routes holds of worker types t export const Server = hostKind("server"); export const Session = hostKind("session", { id: str() }); export const Notes = remote("notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), variants: { … } }); + hostAddress: ({ session }) => Session({ id: session }), variants: { … } }); const server = createHost

({ address: Server(), principal: { kind: "server" }, onError }); server.route(Session, { // before load, one route per kind @@ -125,7 +125,8 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded: - `createHost({ address?, principal?, principalKey?, telemetry?, onError, closeTimeout?, readyTimeout?, heartbeat?, revokedBy?, compression? })`; `R` is the app's revocation type (JSON). `address` (`Session({ id: "S1" })`, see `contracts/`'s `hostKind`) makes the host addressable: it serves only objects placed there; without one it is a client. A host with a `principal` acts as it for its own requests; a host - without one (a client) has none and serves only local types (`serve` of a remote type throws at `load`). + without one (a client) has none. A host serves remote types only with both a principal and an address (`serve` of a + remote type throws at `load` otherwise). `principalKey` maps a principal to its part of an identity (L1's canonical JSON by default; the same function on every host). `closeTimeout` (30 s) bounds each native's close and the drain of an ending object's calls; `readyTimeout` (60 s) bounds every activation @@ -166,7 +167,7 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded: (values, ops, results, arguments; never params, never arguments sent up its parent link) and inflates deflated parts it owns in place. Child links refuse compressed parts (a protocol error: clients never send them, and a small part could inflate to 64 MiB). Without one (a browser) it sends uncompressed and inflates asynchronously - (`DecompressionStream`), only parts it owns once their id and placement are known and their wire size passed, + (`DecompressionStream`), only parts it owns once their id and destination are known and their wire size passed, within the smaller of its link's decoded limit and a hold's stamped one, holding later messages of the link in order; acks go out after a message was applied, so a slow inflate is backpressure like a slow consumer. - **Cycles across hosts (Q5):** every object instance gets an instance id (uuidv7) at its first attempt; a new object @@ -235,17 +236,17 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded: the object activates or retries, while there is no parent link or it is down; `failed` while the object is failed and no attempt runs; `live`. Throws at once for invalid params (`invalid/params`) and a local type not served here (`unavailable`); before `load` resolved - these are decided once it did, and the reference fails `broken` with that error. A placement or `authorize` that - throws or refuses, or a placement whose params fail its kind's check (`invalid/placement`), fails the reference + these are decided once it did, and the reference fails `broken` with that error. A `hostAddress` or `authorize` that + throws or refuses, or a host address whose params fail its kind's check (`invalid/host_address`), fails the reference `broken`. A local type never crosses a link. Where a reference made here (`host.get`/`hold`, `self.get`, `ctx.get`) - or a hold arriving on a link goes, first match (`Placement`; the address is the contract's `placement` of the params, + or a hold arriving on a link goes, first match (`Destination`; the address is the contract's `hostAddress` of the params, computed by the caller; a hold carries it to be routed, and a host serving the type computes it again): | Case | Goes | | --- | --- | - | the type is served here and its address is this host's, or the host or the type has none | here | + | the type is served here and its address is this host's | here | | a reference made here to a served type whose address is another host's | on, as if not served here (rows below): worker S1 reaches `Notes { session: "S2" }` through the parent | - | an arriving hold for a served type whose params place it on another host | broken `invalid/placement` (misrouted; never forwarded) | + | an arriving hold for a served type whose params place it on another host | broken `invalid/host_address` (misrouted; never forwarded) | | the address is of a kind this host has a route for | over that route's link (the params checked against the kind, `authorize` first; `forbidden` when it refuses) | | otherwise (a remote type) | over the parent link, open yet or not; a hold that arrived *on* the parent link is never sent back: `unavailable` | | an arriving hold and no parent link | `unavailable` | @@ -458,7 +459,7 @@ cycles, throwing factories), `06-natives.ts` (opening in order, `not_ready`, clo `08-remote.ts` (two hosts, variants per principal over a shared store, replicas), `09-calls.ts` (calls over a link, bytes, errors, `ctx.principal`, cancellation, release), `10-drops.ts` (a hold before any link, `ref.state`, parked calls, a drop and the re-hold), `11-linger-and-retry.ts` (`linger`, failed references, `ref.retry()`), -`12-routes.ts` (addresses, placement, a route to an in-process worker, a guest's `ctx.get` through it), +`12-routes.ts` (addresses, host addresses, a route to an in-process worker, a guest's `ctx.get` through it), `13-relay.ts` (tab, server and a forked worker over WebSocket and IPC, the worker's start data through its core facet, §18), `14-flow-control.ts` (a slow tab: folding at the provider, and at the worker per origin through a relay while a fast tab keeps receiving), `15-revocation.ts` (a grant revoked: its tabs' links closed, its objects on @@ -488,8 +489,8 @@ delta)` (the keep-alive counts), `cycle(chain, id, closing)` (the `invalid/cycle Inside the directory: `ChordHost` (types, objects by identity, facets, the batch, the backoff map, the endings new instances wait for, its links and the heartbeat timer, its routes and the parent path; `reference` checks a reference's route, `bind` binds it to an object here or a remote object over a path, `acquire` gets one for a -caller), `Placement` (where a reference or an arriving hold goes: here, a route's path or the parent's; whether a hold -served here is placed here), `Route` (one per routed kind: per address its `Path`, at most one running +caller), `Destination` (where a reference or an arriving hold goes: here, a route's path or the parent's; whether a +hold served here is placed here), `Route` (one per routed kind: per address its `Path`, at most one running `resolve`, the backoff per address; an address is kept while it has holders, a link or a running `resolve`), `ChordObject` (one per identity: its state across attempts, holders, retries, cycle checks with its instance id and the chain it was created for, lingering, ending and revocation, the count of its running and finishing job runs, diff --git a/packages/chord/src/next/host/placement.ts b/packages/chord/src/next/host/destination.ts similarity index 56% rename from packages/chord/src/next/host/placement.ts rename to packages/chord/src/next/host/destination.ts index e6011168f..d92fbb0f1 100644 --- a/packages/chord/src/next/host/placement.ts +++ b/packages/chord/src/next/host/destination.ts @@ -1,6 +1,6 @@ -// Placement (§3, section 1's table): where a reference made on a host, or a hold arriving on one of its links, goes: -// here, over the route of its placement's address, or over the parent link; and the check that an object served here -// is placed here. +// Destination (§3, section 1's table): where a reference made on a host, or a hold arriving on one of its links, goes: +// here, over the route of its host address, or over the parent link; and the check that an object served here lives +// here. import type { Context } from "../../context/index.ts"; import type { Address, Contract, ParamsOf } from "../contracts/index.ts"; @@ -12,13 +12,13 @@ import type { HoldRequest, HostLink, Path } from "../remote/internal.ts"; import type { Served } from "./object.ts"; import type { Route } from "./route.ts"; -/** Where something goes: here, or over a path (with the address it was placed at, if its type has placement). */ +/** Where something is placed: here, or over a path (with the host address it was computed or named with). */ export type Placed = | { readonly kind: "here" } - | { readonly kind: "path"; readonly path: Path; readonly address: WireAddress | undefined }; + | { readonly kind: "path"; readonly path: Path; readonly address: WireAddress }; -/** A host's placement: its address, its served types, its routes and its parent path, as the host keeps them. */ -export class Placement { +/** A host's destinations: its address, its served types, its routes and its parent path, as the host keeps them. */ +export class Destination { readonly #address: Address | undefined; readonly #types: ReadonlyMap; readonly #routes: ReadonlyMap; @@ -37,15 +37,16 @@ export class Placement { } /** - * Where a reference to `Type` with `params` made as `principal` goes: here when its type is served here and placed - * here (or this host has no address, or the type no placement); otherwise over the route of its placement's kind, - * or over the parent link (placement decides where an object lives: a worker reaches another worker's object of a - * type it serves too). Throws `invalid/placement` for a placement whose params do not fit its kind, `forbidden` when - * the route refuses, and what a placement or `authorize` throws. + * Where a reference to `Type` with `params` made as `principal` goes: here for a local type (served here: the host + * refused it otherwise) or a remote type served here whose host address is this host's; otherwise over the route of + * its host address's kind, or over the parent link (the host address decides where an object lives: a worker + * reaches another worker's object of a type it serves too). Throws `invalid/host_address` for a host address whose + * params do not fit its kind, `forbidden` when the route refuses, and what a `hostAddress` or `authorize` throws. */ reference(Type: Contract, params: ParamsOf, principal: Principal | undefined, context: Context): Placed { - const address = placementOf(Type, params); - if (this.#types.has(Type.name) && this.#placedHere(address)) return HERE; + if (Type.kind === "local") return HERE; + const address = hostAddressOf(Type, params); + if (this.#types.has(Type.name) && this.#isHere(address)) return HERE; return { kind: "path", path: this.#pathTo(address, principal, context) ?? this.#parent, address }; } @@ -66,40 +67,40 @@ export class Placement { } /** - * Throws `invalid/placement` for a hold of `Type` with `params` (checked) served here but placed on another host - * (misrouted; never forwarded). The placement is computed here from the params: the address a hold names only - * routes it. + * Throws `invalid/host_address` for a hold of `Type` with `params` (checked) served here but whose host address is + * another host (misrouted; never forwarded). The host address is computed here from the params: the address a hold + * names only routes it. */ assertHere(Type: Contract, params: ParamsOf): void { - const address = placementOf(Type, params); - if (this.#placedHere(address)) return; - throw invalid("placement", `${Type.name}: placed on ${address!.kind}, this host is ${this.#address!.key}`); + const address = hostAddressOf(Type, params); + if (this.#isHere(address)) return; + // A host serving a remote type has an address (`serve` refuses one without). + throw invalid("host_address", `${Type.name}: placed on ${address.kind}, this host is ${this.#address!.key}`); } /** * The path to `address` when this host routes its kind (its params checked, `authorize` run for `principal`); - * `undefined` without an address or a route for its kind. + * `undefined` without a route for its kind. */ - #pathTo(address: WireAddress | undefined, principal: Principal | undefined, context: Context): Path | undefined { - if (address === undefined) return undefined; + #pathTo(address: WireAddress, principal: Principal | undefined, context: Context): Path | undefined { return this.#routes.get(address.kind)?.path(address.params, principal, context); } - /** Whether an object placed at `address` lives here: this host has no address, the type no placement, or equal. */ - #placedHere(address: WireAddress | undefined): boolean { + /** Whether an object whose host address is `address` lives here: this host has that address. */ + #isHere(address: WireAddress): boolean { const own = this.#address; - return own === undefined || address === undefined || sameAddress(own, address); + return own !== undefined && sameAddress(own, address); } } const HERE: Placed = { kind: "here" }; -/** The address `Type`'s placement computes for `params`; `undefined` for a type without placement. */ -function placementOf(Type: Contract, params: ParamsOf): WireAddress | undefined { - const placement = contractData(Type).placement; - if (placement === undefined) return undefined; +/** The address a remote `Type`'s `hostAddress` computes for `params`. */ +function hostAddressOf(Type: Contract, params: ParamsOf): WireAddress { + // Every remote contract has one (declaration policy, `remote`). + const hostAddress = contractData(Type).hostAddress!; // Plugin code: what it returns is checked where it is routed or served. - const { kind, params: values } = placement(params) as { readonly kind: string; readonly params: unknown }; + const { kind, params: values } = hostAddress(params) as { readonly kind: string; readonly params: unknown }; return { kind, params: values }; } diff --git a/packages/chord/src/next/host/host.ts b/packages/chord/src/next/host/host.ts index 5b360e4ba..5612e562a 100644 --- a/packages/chord/src/next/host/host.ts +++ b/packages/chord/src/next/host/host.ts @@ -48,11 +48,11 @@ import { Activity, type ActivityCounts } from "./activity.ts"; import { notLive } from "./attempt.ts"; import { Backoff } from "./backoff.ts"; import { type Caller, type Ctx, withLinkedAbortSignal } from "./ctx.ts"; +import { Destination, type Placed } from "./destination.ts"; import { type Facet, type FacetContribution, type ServedEntry, setUp } from "./facet.ts"; import { contributionOf } from "./members.ts"; import type { CloseFailed } from "./native.ts"; import { ChordObject, type Holder, type LocalContract, type RemoteContract, type Served } from "./object.ts"; -import { type Placed, Placement } from "./placement.ts"; import { type Presence, References } from "./presence.ts"; import { Binding, type CheckedParams, type Held, type Reference, type Target } from "./reference.ts"; import { RevocationKeys, type RevokedBy } from "./revocation.ts"; @@ -191,8 +191,8 @@ export interface Host

}[], context: Context): Promise; /** * A reference once its object is live; waits for `load` first. It acts as the host's principal and goes where its - * type's placement puts it: here, over a route, or over the parent link (`Placement`). A broken failure rejects at - * once; through transient ones it waits until its deadline, then rejects with the last failure's error. + * type's host address puts it: here, over a route, or over the parent link (`Destination`). A broken failure + * rejects at once; through transient ones it waits until its deadline, then rejects with the last failure's error. */ get(Type: C, params: ParamsOf, context: Context, options?: GetOptions): Promise>; /** @@ -316,7 +316,7 @@ export class ChordHost

{}); readonly #routes = new Map(); - readonly #placement: Placement; + readonly #destination: Destination; /** The downstream origins this host relays holds for, by their number on upstream links (never reused). */ readonly relays = new Map(); #nextOrigin = 1; @@ -344,7 +344,7 @@ export class ChordHost

| undefined); this.timeouts = { close: options.closeTimeout ?? 30_000, ready: options.readyTimeout ?? 60_000 }; this.#heartbeat = { ping: options.heartbeat?.ping ?? 15_000, close: options.heartbeat?.close ?? 30_000 }; - this.#placement = new Placement(this.address, this.#types, this.#routes, this.#parent); + this.#destination = new Destination(this.address, this.#types, this.#routes, this.#parent); // Chord's own: `openStore` made it. this.store = options.store as ChordStore | undefined; this.store?.attach({ failed: (error) => this.#storageFailed(error), batch: this.batch }); @@ -576,13 +576,13 @@ export class ChordHost

, principal: Principal | undefined): Path { // A retry has no context of its own: its admission checks count on none. - const placed = this.#placement.reference(Type, params, principal, BACKGROUND_CONTEXT); + const placed = this.#destination.reference(Type, params, principal, BACKGROUND_CONTEXT); if (placed.kind === "path") return placed.path; throw new ChordError({ code: "unavailable", message: `${Type.name} is served here now` }); } @@ -713,8 +713,8 @@ export class ChordHost

(ADDRESS | PRINCIPAL | INCARNATION | CHAIN | LIMIT)) throw new Error("binary: unknown hold flags"); - const address = flags & ADDRESS ? { address: { kind: reader.string(), params: reader.json() } } : {}; + if (flags > (PRINCIPAL | INCARNATION | CHAIN | LIMIT)) throw new Error("binary: unknown hold flags"); const principal = flags & PRINCIPAL ? { principal: reader.json() as { kind: string } } : {}; const incarnation = flags & INCARNATION ? { incarnation: uuid(reader.raw(16)) } : {}; const chain = flags & CHAIN ? { chain: reader.list(() => uuid(reader.raw(16))) } : {}; @@ -221,7 +216,7 @@ function decodeFields(kind: (typeof KINDS)[number], reader: Reader): Message { version, fingerprint, params, - ...address, + address, ...principal, ...incarnation, ...chain, diff --git a/packages/chord/src/next/link/message.ts b/packages/chord/src/next/link/message.ts index a8c022571..84bab8629 100644 --- a/packages/chord/src/next/link/message.ts +++ b/packages/chord/src/next/link/message.ts @@ -62,8 +62,8 @@ const MESSAGE = union([ version: int({ min: 1 }), fingerprint: str({ pattern: "^[0-9a-f]{16}$" }), params: part, - // The address the caller computed (absent for a type without placement). - address: opt(address), + // The address the caller computed with the type's `hostAddress`: it routes the hold. + address, // Whom the hold acts for and the origin's incarnation; read only on a parent link. principal: opt(principal), incarnation: opt(incarnation), diff --git a/packages/chord/src/next/link/text.ts b/packages/chord/src/next/link/text.ts index c9b7b8e0d..e20c741bb 100644 --- a/packages/chord/src/next/link/text.ts +++ b/packages/chord/src/next/link/text.ts @@ -75,16 +75,10 @@ function ownTuple(message: Message): string { const { address, principal, incarnation, chain, limit } = message; const head = json(["H", message.hold, message.type, message.version, message.fingerprint]).slice(0, -1); // Optional trailing fields: an absent one in the middle is null, absent ones at the end are left out. - const optional = [ - address === undefined ? null : [address.kind, address.params], - principal ?? null, - incarnation ?? null, - chain ?? null, - limit ?? null, - ]; + const optional = [principal ?? null, incarnation ?? null, chain ?? null, limit ?? null]; while (optional.length > 0 && optional.at(-1) === null) optional.pop(); const rest = optional.map((field) => `,${json(field)}`).join(""); - return `${head},${partText(message.params)}${rest}]`; + return `${head},${partText(message.params)},${json([address.kind, address.params])}${rest}]`; } case "release": return json(["R", message.hold]); @@ -188,14 +182,10 @@ function fromFields(fields: unknown[], rest: Uint8Array): Message { arity(3); return { kind: "hello", protocol: field(0), incarnation: field(1), calls: field(2) }; case "H": { - arity(5, 6, 7, 8, 9, 10); - const [address, principal, incarnation, chain, limit] = [5, 6, 7, 8, 9].map((index) => f[index] ?? null); - let wireAddress = {}; - if (address !== null) { - const pair = list(address); - if (pair.length !== 2) throw new Error("text: an address is a pair"); - wireAddress = { address: { kind: pair[0], params: pair[1] } }; - } + arity(6, 7, 8, 9, 10); + const address = list(f[5]); + if (address.length !== 2) throw new Error("text: an address is a pair"); + const [principal, incarnation, chain, limit] = [6, 7, 8, 9].map((index) => f[index] ?? null); return { kind: "hold", hold: field(0), @@ -203,7 +193,7 @@ function fromFields(fields: unknown[], rest: Uint8Array): Message { version: field(2), fingerprint: field(3), params: textPart(f[4]), - ...wireAddress, + address: { kind: address[0] as string, params: address[1] }, ...(principal === null ? {} : { principal: principal as { kind: string } }), ...(incarnation === null ? {} : { incarnation: incarnation as string }), ...(chain === null ? {} : { chain: chain as string[] }), diff --git a/packages/chord/src/next/remote/README.md b/packages/chord/src/next/remote/README.md index cd27471a5..16522f71b 100644 --- a/packages/chord/src/next/remote/README.md +++ b/packages/chord/src/next/remote/README.md @@ -83,7 +83,7 @@ within each link's limits, compression at the origin, activation chains and revo `state` with A's and B's entries: the derive sees both at once, never A new with B old. - `Hold` (one per `hold` served here, a `Holder` of its object): takes its object once `load` resolved; the hold's `params` part is parsed then (malformed: a protocol error). A refused type, a mismatch, invalid params, a wrong - placement or a snapshot that throws is a broken `failed` that ends it (the provider forgets the id). While pending + host address or a snapshot that throws is a broken `failed` that ends it (the provider forgets the id). While pending it follows its object's `state`: each failure, transient or broken, is sent as `failed` with `retryIn`, the rest of the object's backoff window (absent when broken), the current one at once when the object is failed already; the next attempt is announced with `loading`; the hold stays, so a `retry` (from any holder) renews even a broken object @@ -146,7 +146,7 @@ within each link's limits, compression at the origin, activation chains and revo until `retry`; the message's other entries apply. Its call arguments go up a parent link uncompressed (the parent may have accepted it as a child). It keeps its binding's caller: two references with different principals are two remote objects. Its holds carry the params as a - part and the address its placement computed. A `bound` (a first binding, a re-hold; also on a live hold) + part and the address its `hostAddress` computed. A `bound` (a first binding, a re-hold; also on a live hold) replaces the replicas in the message's batch: members it had get the snapshot as one frame `[["r", value]]` and a fresh decoder, members outside a new variant vanish (reads throw `invalid/member`, watches get one `failed` call and stay open, derives over them fail; a later binding with the member brings it back as `[["r", value]]`, as on the diff --git a/packages/chord/src/next/remote/hold.ts b/packages/chord/src/next/remote/hold.ts index 59ee856c9..b018c201b 100644 --- a/packages/chord/src/next/remote/hold.ts +++ b/packages/chord/src/next/remote/hold.ts @@ -72,7 +72,7 @@ type HoldState = /** The members it follows, in the order they were subscribed. */ readonly followed: Followed[]; } - /** Refused where it arrived (placement, `authorize`, a mismatch, invalid params, a cycle): `retry` starts it again. */ + /** Refused where it arrived (host address, `authorize`, a mismatch, invalid params, a cycle): `retry` restarts it. */ | { readonly kind: "refused"; readonly request: HoldRequest } | { readonly kind: "released" }; @@ -86,8 +86,8 @@ const BINDING: HoldState = { kind: "binding" }; /** * One hold served here: a reference to its object acting as its caller (the hold's principal). While pending, each * failure of its object is sent as `failed` (with the rest of its backoff window) and the next attempt as `loading`; a - * hold that cannot be taken (a refused type, a mismatch, invalid params or placement) is a broken `failed` that holds - * nothing until a `retry` starts it again. Once live it binds when its link's window has a slot, then sends a part per batch that changed its members + * hold that cannot be taken (a refused type, a mismatch, invalid params or a wrong host address) is a broken `failed` + * that holds nothing until a `retry` starts it again. Once live it binds when its link's window has a slot, then sends a part per batch that changed its members * (or keeps their ops pending while the window is full), and `gone` when its object ends. */ export class Hold implements Holder { @@ -139,7 +139,8 @@ export class Hold implements Holder { * Admits the hold once `load` resolved: its params within the link's part limit (`too_large/part`), then relayed * (it is replaced by a relayed hold), or served here, its params opened and parsed (a malformed part is a protocol * error), its object taken and followed (`moved`). A hold that cannot go anywhere, a type that does not match, - * invalid params, a wrong placement or a cycle (Q5) is a broken `failed`; the hold is kept, refused, for a `retry`. + * invalid params, a wrong host address or a cycle (Q5) is a broken `failed`; the hold is kept, refused, for a + * `retry`. */ async start(request: HoldRequest): Promise { const { link } = this.#provider; diff --git a/packages/chord/src/next/remote/remote-object.ts b/packages/chord/src/next/remote/remote-object.ts index 1765b7d1d..05ea9cea9 100644 --- a/packages/chord/src/next/remote/remote-object.ts +++ b/packages/chord/src/next/remote/remote-object.ts @@ -86,7 +86,7 @@ export class RemoteObject implements Target { host: AnyHost, contract: Contract, { params, key }: CheckedParams, - address: WireAddress | undefined, + address: WireAddress, caller: Caller, path: Path, dependent: ChordObject | undefined, @@ -100,7 +100,7 @@ export class RemoteObject implements Target { version, fingerprint, params: jsonPart(params), - ...(address === undefined ? {} : { address }), + address, }; this.caller = caller; this.#params = params; @@ -216,7 +216,7 @@ export class RemoteObject implements Target { /** * `ref.retry()`: a failed object's provider is asked for an attempt (a hold it refused is started there again). One * that failed for a part past the link's limits is placed anew and held with a new id, now or on its path's next - * link (a placement that throws fails it broken again). + * link (a `hostAddress` that throws fails it broken again). */ retry(): void { if (this.#state.kind !== "failed") return; @@ -227,7 +227,7 @@ export class RemoteObject implements Target { this.#hold?.side.retry(this.#hold.id); } - /** Places a reference whose part was past the limits anew, on its path now (a placement that throws fails it). */ + /** Places a reference whose part was past the limits anew, on its path now (a throwing `hostAddress` fails it). */ #place(): void { let path: Path; try { diff --git a/packages/chord/test/next/bench/compression.ts b/packages/chord/test/next/bench/compression.ts index 7b74ad8f8..15e7024ce 100644 --- a/packages/chord/test/next/bench/compression.ts +++ b/packages/chord/test/next/bench/compression.ts @@ -4,13 +4,15 @@ // Run by hand from packages/chord (not in CI): // node --conditions=source --experimental-strip-types --expose-gc test/next/bench/compression.ts import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts"; -import { arr, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { arr, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { type Compression, type Link, memoryLinkPair } from "../../../src/next/link/index.ts"; import { DeflatedPart, inflateAsync } from "../../../src/next/link/internal.ts"; import { nodeCompression } from "../../../src/next-node/compression.ts"; +const Server = hostKind("server"); + const context = BACKGROUND_CONTEXT; type P = { kind: "owner" } | { kind: "server" }; @@ -23,6 +25,7 @@ const Store = local("bench.store", { version: 1, params: {}, members: { doc: obj const View = remote("bench.view", { version: 1, params: {}, + hostAddress: () => Server(), variants: { owner: { state: { doc: obj({ entries: arr(Entry) }) }, methods: { export: method(undefined, str()) } } }, }); @@ -115,7 +118,12 @@ for (const [name, json] of [ /** A server compressing with `server` and a client with `client` (none: it inflates asynchronously). */ async function linked(server: Compression | undefined, client: Compression | undefined) { - const serverHost = createHost

({ principal: { kind: "server" }, compression: server, onError: console.error }); + const serverHost = createHost

({ + address: Server(), + principal: { kind: "server" }, + compression: server, + onError: console.error, + }); await serverHost.load([{ name: "@bench/app", facet }], context); const clientHost = createHost

({ compression: client, onError: console.error }); await clientHost.load([], context); diff --git a/packages/chord/test/next/bench/links.ts b/packages/chord/test/next/bench/links.ts index 3bbbf6d31..39e7e7c55 100644 --- a/packages/chord/test/next/bench/links.ts +++ b/packages/chord/test/next/bench/links.ts @@ -8,13 +8,15 @@ import { GCProfiler } from "node:v8"; import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry"; import { BACKGROUND_CONTEXT, type Context, withTelemetry } from "../../../src/context/index.ts"; -import { arr, bytes, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { arr, bytes, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet, type Host, type RefState } from "../../../src/next/host/index.ts"; import { type Codec, type Link, memoryLinkPair } from "../../../src/next/link/index.ts"; import { binaryCodec, type Message, textCodec } from "../../../src/next/link/internal.ts"; import type { NativeWatchable } from "../../../src/watch.ts"; +const Server = hostKind("server"); + type P = { kind: "owner" } | { kind: "server" }; const Entry = obj({ id: int(), text: str(), author: str(), at: int() }); @@ -37,6 +39,7 @@ const Store = local("bench.store", { const View = remote("bench.view", { version: 1, params: { name: str() }, + hostAddress: () => Server(), variants: { owner: { state: { doc: Shape } } }, }); @@ -44,6 +47,7 @@ const View = remote("bench.view", { const Files = remote("bench.files", { version: 1, params: {}, + hostAddress: () => Server(), variants: { owner: { methods: { ping: method(obj({ n: int() }), int()), put: method(obj({ data: bytes() }), int()) } }, }, @@ -138,6 +142,7 @@ const document = (): Entry[] => Array.from({ length: 500 }, (_, id) => entry(id, async function setup(name: string, entries: Entry[], codec: Codec, metered = true) { const context = metered ? counting : BACKGROUND_CONTEXT; const server = createHost

({ + address: Server(), principal: { kind: "server" }, telemetry: metered ? telemetry : undefined, onError: console.error, diff --git a/packages/chord/test/next/bench/relay.ts b/packages/chord/test/next/bench/relay.ts index 4f2c382b0..c699e9282 100644 --- a/packages/chord/test/next/bench/relay.ts +++ b/packages/chord/test/next/bench/relay.ts @@ -39,7 +39,7 @@ const Store = local("bench.store", { const View = remote("bench.view", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { owner: { state: { doc: Shape }, methods: { stream: method(str()), ping: method(int(), int()) } } }, }); diff --git a/packages/chord/test/next/contracts/accepts.test.ts b/packages/chord/test/next/contracts/accepts.test.ts index 83cbfcca9..7091d9d7e 100644 --- a/packages/chord/test/next/contracts/accepts.test.ts +++ b/packages/chord/test/next/contracts/accepts.test.ts @@ -3,6 +3,7 @@ import { type AcceptsTable, arr, evolve, + hostKind, int, local, method, @@ -13,12 +14,15 @@ import { } from "../../../src/next/contracts/index.ts"; import { checkAccepts } from "../../../src/next/contracts/internal.ts"; +const Server = hostKind("server"); + const Note = obj({ text: str() }); const NoteV2 = obj({ text: str(), tag: str() }); const NotesV1 = remote("notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(Note, str()), clear: method() } }, reader: { state: { notes: arr(Note) }, methods: { add: method(Note, str()) } }, diff --git a/packages/chord/test/next/contracts/contract.test.ts b/packages/chord/test/next/contracts/contract.test.ts index c9a6f12ab..e7c409022 100644 --- a/packages/chord/test/next/contracts/contract.test.ts +++ b/packages/chord/test/next/contracts/contract.test.ts @@ -19,11 +19,12 @@ import { import { checkAccepts, currentVariants, LOCAL, takesNoArgs } from "../../../src/next/contracts/internal.ts"; import { bindProvider, checkParams } from "../../../src/next/data/internal.ts"; import type { Watchable } from "../../../src/next/state/index.ts"; -import { expectCollected } from "../support.ts"; +import { expectCollected, Server } from "../support.ts"; const Notes = remote("notes", { version: 1, params: { session: str(), page: int() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() }), str()), clear: method() } }, reader: { state: { notes: arr(str()) } }, diff --git a/packages/chord/test/next/contracts/declare.test.ts b/packages/chord/test/next/contracts/declare.test.ts index 016fb2f90..25e4e0811 100644 --- a/packages/chord/test/next/contracts/declare.test.ts +++ b/packages/chord/test/next/contracts/declare.test.ts @@ -1,10 +1,25 @@ import Type from "typebox"; import { describe, expect, it } from "vitest"; -import { arr, evolve, int, keyed, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { + arr, + evolve, + hostKind, + int, + keyed, + local, + method, + obj, + remote, + str, +} from "../../../src/next/contracts/index.ts"; import { currentVariants, LOCAL } from "../../../src/next/contracts/internal.ts"; const Note = obj({ text: str() }); +const Server = hostKind("server"); + +const hostAddress = () => Server(); + /** Declares through an untyped door, as a JavaScript caller would. */ const untyped = { remote: remote as (name: unknown, declaration: unknown) => unknown, @@ -16,6 +31,7 @@ describe("declaration checks", () => { const Notes = remote("notes", { version: 1, params: { session: str() }, + hostAddress, variants: { editor: { state: { notes: arr(Note) }, methods: { add: method(Note, str()), clear: method() } }, reader: { state: { notes: arr(Note) } }, @@ -31,77 +47,109 @@ describe("declaration checks", () => { expect(Info).toMatchObject({ kind: "local", name: "app.session-info", version: 3 }); }); + // Each case declares a valid `hostAddress` unless it checks that one, so the check it names is the one reached. it.each([ - ["empty name", "", { version: 1, params: {}, variants: { a: {} } }, 'A contract name "": expected lowercase'], + [ + "empty name", + "", + { version: 1, params: {}, hostAddress, variants: { a: {} } }, + 'A contract name "": expected lowercase', + ], [ "name not a string", 1, - { version: 1, params: {}, variants: { a: {} } }, + { version: 1, params: {}, hostAddress, variants: { a: {} } }, "A contract name 1: expected lowercase", ], - ["an uppercase name", "Notes", { version: 1, params: {}, variants: { a: {} } }, "expected lowercase"], - ["a name with a colon", "a:b", { version: 1, params: {}, variants: { a: {} } }, "expected lowercase"], - ["version zero", "n", { version: 0, params: {}, variants: { a: {} } }, "n.version: expected a positive integer"], + [ + "an uppercase name", + "Notes", + { version: 1, params: {}, hostAddress, variants: { a: {} } }, + "expected lowercase", + ], + [ + "a name with a colon", + "a:b", + { version: 1, params: {}, hostAddress, variants: { a: {} } }, + "expected lowercase", + ], + [ + "version zero", + "n", + { version: 0, params: {}, hostAddress, variants: { a: {} } }, + "n.version: expected a positive integer", + ], [ "fractional version", "n", - { version: 1.5, params: {}, variants: { a: {} } }, + { version: 1.5, params: {}, hostAddress, variants: { a: {} } }, "n.version: expected a positive integer", ], [ "unsafe version", "n", - { version: 2 ** 53, params: {}, variants: { a: {} } }, + { version: 2 ** 53, params: {}, hostAddress, variants: { a: {} } }, "n.version: expected a positive integer", ], [ "unknown declaration key", "n", - { version: 1, params: {}, variants: { a: {} }, place: "x" }, + { version: 1, params: {}, hostAddress, variants: { a: {} }, place: "x" }, 'n: unknown key "place"', ], [ - "a placement that is no function", + "a host address that is no function", "n", - { version: 1, params: {}, variants: { a: {} }, placement: "x" }, - "n.placement: expected a function of the params", + { version: 1, params: {}, variants: { a: {} }, hostAddress: "x" }, + "n.hostAddress: expected a function of the params", ], - ["no variants", "n", { version: 1, params: {}, variants: {} }, "n.variants: at least one variant is required"], - ["missing params", "n", { version: 1, variants: { a: {} } }, "n.params: expected an object"], + [ + "no host address", + "n", + { version: 1, params: {}, variants: { a: {} } }, + "n.hostAddress: expected a function of the params", + ], + [ + "no variants", + "n", + { version: 1, params: {}, hostAddress, variants: {} }, + "n.variants: at least one variant is required", + ], + ["missing params", "n", { version: 1, hostAddress, variants: { a: {} } }, "n.params: expected an object"], [ "unknown variant key", "n", - { version: 1, params: {}, variants: { a: { members: {} } } }, + { version: 1, params: {}, hostAddress, variants: { a: { members: {} } } }, 'n.a: unknown key "members"', ], [ "a schema under methods", "n", - { version: 1, params: {}, variants: { a: { methods: { m: str() } } } }, + { version: 1, params: {}, hostAddress, variants: { a: { methods: { m: str() } } } }, "n.a.methods.m: expected method(...)", ], [ "a method under state", "n", - { version: 1, params: {}, variants: { a: { state: { s: method() } } } }, + { version: 1, params: {}, hostAddress, variants: { a: { state: { s: method() } } } }, "n.a.state.s: a method belongs in methods", ], [ "bad state schema", "n", - { version: 1, params: {}, variants: { a: { state: { s: { not: {} } } } } }, + { version: 1, params: {}, hostAddress, variants: { a: { state: { s: { not: {} } } } } }, 'n.a.state.s: unsupported keyword "not"', ], [ "open arguments", "n", - { version: 1, params: {}, variants: { a: { methods: { m: method(Type.Object({})) } } } }, + { version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(Type.Object({})) } } } }, "n.a.methods.m.args: argument objects must be closed", ], [ "bad result", "n", - { version: 1, params: {}, variants: { a: { methods: { m: method(undefined, { not: {} }) } } } }, + { version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(undefined, { not: {} }) } } } }, 'n.a.methods.m.result: unsupported keyword "not"', ], ])("remote refuses %s", (_case, name, declaration, message) => { @@ -110,7 +158,7 @@ describe("declaration checks", () => { it.each([ ["unknown key", { version: 1, params: {}, members: {}, variants: {} }, 'i: unknown key "variants"'], - ["a placement", { version: 1, params: {}, members: {}, placement: () => null }, 'i: unknown key "placement"'], + ["a host address", { version: 1, params: {}, members: {}, hostAddress }, 'i: unknown key "hostAddress"'], [ "bad member schema", { version: 1, params: {}, members: { s: { not: {} } } }, @@ -125,6 +173,13 @@ describe("declaration checks", () => { expect(() => untyped.local("i", declaration)).toThrow(message); }); + it("refuse a remote declaration without a host address at the type level", () => { + expect(() => + // @ts-expect-error: every remote type declares its host address + remote("n", { version: 1, params: {}, variants: { a: {} } }), + ).toThrow("n.hostAddress: expected a function of the params"); + }); + it("does not check state for closed objects", () => { expect(() => local("i", { version: 1, params: {}, members: { s: Type.Object({ a: str() }) } })).not.toThrow(); }); @@ -136,6 +191,7 @@ describe("D11: members across variants", () => { remote("n", { version: 1, params: {}, + hostAddress, // @ts-expect-error: `x` is a method in variant b variants: { a: { state: { x: str() } }, b: { methods: { x: method() } } }, }), @@ -147,6 +203,7 @@ describe("D11: members across variants", () => { remote("n", { version: 1, params: {}, + hostAddress, // @ts-expect-error: `x` is both state and a method variants: { a: { state: { x: str() }, methods: { x: method() } } }, }), @@ -158,6 +215,7 @@ describe("D11: members across variants", () => { remote("n", { version: 1, params: {}, + hostAddress, // @ts-expect-error: `m` takes a string in a and an integer in b variants: { a: { methods: { m: method(str()) } }, b: { methods: { m: method(int()) } } }, }), @@ -166,6 +224,7 @@ describe("D11: members across variants", () => { remote("n", { version: 1, params: {}, + hostAddress, variants: { a: { methods: { m: method(str(), str()) } }, b: { methods: { m: method(str({ max: 3 }), str()) } }, @@ -179,6 +238,7 @@ describe("D11: members across variants", () => { remote("n", { version: 1, params: {}, + hostAddress, variants: { editor: { state: { notes: arr(obj({ text: str(), author: str() })) }, methods: { m: method(str()) } }, reader: { state: { notes: arr(obj({ text: str() })) }, methods: { m: method(str()) } }, @@ -190,9 +250,9 @@ describe("D11: members across variants", () => { describe("reserved member names", () => { it.each(["then", "release", "variant", "state", "retry"])("refuses %s in remote, local and evolve", (name) => { - expect(() => remote("n", { version: 1, params: {}, variants: { v: { state: { [name]: str() } } } })).toThrow( - `n.v.state.${name}: "${name}" is reserved: a reference has it`, - ); + expect(() => + remote("n", { version: 1, params: {}, hostAddress, variants: { v: { state: { [name]: str() } } } }), + ).toThrow(`n.v.state.${name}: "${name}" is reserved: a reference has it`); expect(() => local("n", { version: 1, params: {}, members: { [name]: method() } })).toThrow( `n.members.${name}: "${name}" is reserved: a reference has it`, ); @@ -220,6 +280,7 @@ describe("keyed members (slice 6a §12)", () => { remote("app.notes", { version: 1, params: {}, + hostAddress, // @ts-expect-error keyed members are only in local contracts variants: { editor: { state: { notes: keyed(Note) } } }, }), diff --git a/packages/chord/test/next/contracts/evolve.test.ts b/packages/chord/test/next/contracts/evolve.test.ts index abc2263e3..85c1d20eb 100644 --- a/packages/chord/test/next/contracts/evolve.test.ts +++ b/packages/chord/test/next/contracts/evolve.test.ts @@ -7,6 +7,7 @@ import { arr, bool, evolve, + hostKind, type Implementation, int, local, @@ -18,11 +19,14 @@ import { str, } from "../../../src/next/contracts/index.ts"; +const Server = hostKind("server"); + const Note = obj({ text: str() }); const NotesV1 = remote("notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(Note, str()), clear: method() } }, reader: { state: { notes: arr(Note) }, methods: { add: method(Note, str()) } }, @@ -164,7 +168,12 @@ describe("kinds and fixed parts", () => { }); it("a new base is a full declaration with an explicit version and no older versions", () => { - const base = remote("notes", { version: 5, params: { session: str(), tenant: str() }, variants: { editor: {} } }); + const base = remote("notes", { + version: 5, + params: { session: str(), tenant: str() }, + hostAddress: () => Server(), + variants: { editor: {} }, + }); expect(contractData(base).versions.map(({ version }) => version)).toEqual([5]); expect(evolve(base, (e) => e).version).toBe(6); }); diff --git a/packages/chord/test/next/contracts/fingerprint.test.ts b/packages/chord/test/next/contracts/fingerprint.test.ts index d28e360fb..f159b7856 100644 --- a/packages/chord/test/next/contracts/fingerprint.test.ts +++ b/packages/chord/test/next/contracts/fingerprint.test.ts @@ -1,12 +1,15 @@ import Type, { type TSchema } from "typebox"; import { describe, expect, it } from "vitest"; -import { evolve, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { evolve, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { NORMAL_FORM_VERSION } from "../../../src/next/contracts/internal.ts"; import type { Params } from "../../../src/next/contracts/params.ts"; +const Server = hostKind("server"); + /** Fingerprint of a one-variant remote contract with state `s` and params `params`. */ function fingerprintOf(state: TSchema, params: Params = {}): string { - return remote("n", { version: 1, params, variants: { a: { state: { s: state } } } }).fingerprint; + return remote("n", { version: 1, params, hostAddress: () => Server(), variants: { a: { state: { s: state } } } }) + .fingerprint; } describe("fingerprints", () => { @@ -15,6 +18,7 @@ describe("fingerprints", () => { const Notes = remote("notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: obj({ text: str() }) }, methods: { add: method(obj({ text: str() }), str()) } }, reader: { state: { notes: obj({ text: str() }) } }, @@ -58,7 +62,8 @@ describe("fingerprints", () => { }); it("give the same v1 from a separate declaration, and evolve keeps v1's", () => { - const declare = () => remote("n", { version: 1, params: {}, variants: { a: { state: { s: str() } } } }); + const declare = () => + remote("n", { version: 1, params: {}, hostAddress: () => Server(), variants: { a: { state: { s: str() } } } }); const v1 = declare(); const v2 = evolve(v1, (e) => e.state("a", "s", int())); expect(declare().fingerprint).toBe(v1.fingerprint); @@ -67,14 +72,19 @@ describe("fingerprints", () => { }); it("differ between local and remote", () => { - const asRemote = remote("n", { version: 1, params: {}, variants: { members: { state: { s: str() } } } }); + const asRemote = remote("n", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { members: { state: { s: str() } } }, + }); const asLocal = local("n", { version: 1, params: {}, members: { s: str() } }); expect(asLocal.fingerprint).not.toBe(asRemote.fingerprint); }); it("do not depend on the name or version", () => { - const a = remote("a", { version: 1, params: {}, variants: { v: {} } }); - const b = remote("b", { version: 7, params: {}, variants: { v: {} } }); + const a = remote("a", { version: 1, params: {}, hostAddress: () => Server(), variants: { v: {} } }); + const b = remote("b", { version: 7, params: {}, hostAddress: () => Server(), variants: { v: {} } }); expect(a.fingerprint).toBe(b.fingerprint); }); }); diff --git a/packages/chord/test/next/contracts/host-kind.test.ts b/packages/chord/test/next/contracts/host-kind.test.ts index 3414ffd97..93026d54d 100644 --- a/packages/chord/test/next/contracts/host-kind.test.ts +++ b/packages/chord/test/next/contracts/host-kind.test.ts @@ -30,7 +30,7 @@ describe("host kinds", () => { expect(hostKind("@pi/session.v2_~-").kind).toBe("@pi/session.v2_~-"); }); - it("check an address's params where it is routed, with the kind's compiled check: invalid/placement", () => { + it("check an address's params where it is routed, with the kind's compiled check: invalid/host_address", () => { const params = kindParams(Session); expect(checkAddress("session", params, { id: "S1" }, context).key).toBe("session|S1"); for (const bad of [{ id: 1 }, {}, { id: "S1", extra: 1 }, null]) { @@ -40,7 +40,7 @@ describe("host kinds", () => { } catch (error) { thrown = error; } - expect(isChordError(thrown, "invalid") && thrown.reason).toBe("placement"); + expect(isChordError(thrown, "invalid") && thrown.reason).toBe("host_address"); } }); @@ -54,18 +54,22 @@ describe("host kinds", () => { }); }); -describe("placement", () => { +describe("hostAddress", () => { const variants = { v: { state: { n: int() } } }; it("is not part of the fingerprint and is carried by evolve", () => { - const placement = ({ session }: { session: string }) => Session({ id: session }); - const placed = remote("app.placed", { version: 1, params: { session: str() }, placement, variants }); - const unplaced = remote("app.placed", { version: 1, params: { session: str() }, variants }); - expect(placed.fingerprint).toBe(unplaced.fingerprint); - expect(contractData(placed).placement).toBe(placement); - expect(contractData(unplaced).placement).toBeUndefined(); - const next = evolve(placed, (v) => v.addState("v", "m", int())); - expect(contractData(next).placement).toBe(placement); + const hostAddress = ({ session }: { session: string }) => Session({ id: session }); + const onSession = remote("app.placed", { version: 1, params: { session: str() }, hostAddress, variants }); + const onServer = remote("app.placed", { + version: 1, + params: { session: str() }, + hostAddress: () => Server(), + variants, + }); + expect(onSession.fingerprint).toBe(onServer.fingerprint); + expect(contractData(onSession).hostAddress).toBe(hostAddress); + const next = evolve(onSession, (v) => v.addState("v", "m", int())); + expect(contractData(next).hostAddress).toBe(hostAddress); }); it("is typed by the contract's params", () => { @@ -73,7 +77,7 @@ describe("placement", () => { version: 1, params: { session: str() }, // @ts-expect-error: the params have no `id` - placement: ({ id }) => Session({ id }), + hostAddress: ({ id }) => Session({ id }), variants, }); }); diff --git a/packages/chord/test/next/contracts/params.test.ts b/packages/chord/test/next/contracts/params.test.ts index 159ff72ee..96187b2b0 100644 --- a/packages/chord/test/next/contracts/params.test.ts +++ b/packages/chord/test/next/contracts/params.test.ts @@ -1,6 +1,20 @@ import Type from "typebox"; import { describe, expect, it } from "vitest"; -import { arr, bool, bytes, int, num, obj, oneOf, opt, remote, str } from "../../../src/next/contracts/index.ts"; +import { + arr, + bool, + bytes, + hostKind, + int, + num, + obj, + oneOf, + opt, + remote, + str, +} from "../../../src/next/contracts/index.ts"; + +const Server = hostKind("server"); const variants = { a: { state: { s: str() } } }; @@ -15,16 +29,17 @@ describe("params policy", () => { c: bool(), d: oneOf("chat", "agent"), }, + hostAddress: () => Server(), variants, }), ).not.toThrow(); }); it("refuses optional fields, in the types and at declaration", () => { - // @ts-expect-error: params fields are required - expect(() => remote("n", { version: 1, params: { a: opt(str()) }, variants })).toThrow( - "n.params.a: a param is a required str(), int(), bool() or oneOf(...) of strings", - ); + expect(() => + // @ts-expect-error: params fields are required + remote("n", { version: 1, params: { a: opt(str()) }, hostAddress: () => Server(), variants }), + ).toThrow("n.params.a: a param is a required str(), int(), bool() or oneOf(...) of strings"); }); it.each([ @@ -36,6 +51,8 @@ describe("params policy", () => { ["defaults", Type.String({ default: "x" })], ])("refuses %s, in the types and at declaration", (_case, schema) => { // @ts-expect-error: not a params field schema - expect(() => remote("n", { version: 1, params: { p: schema }, variants })).toThrow("n.params.p: a param is"); + expect(() => remote("n", { version: 1, params: { p: schema }, hostAddress: () => Server(), variants })).toThrow( + "n.params.p: a param is", + ); }); }); diff --git a/packages/chord/test/next/data/args.test.ts b/packages/chord/test/next/data/args.test.ts index 90dc8cae6..896861fc0 100644 --- a/packages/chord/test/next/data/args.test.ts +++ b/packages/chord/test/next/data/args.test.ts @@ -15,7 +15,7 @@ import { } from "../../../src/next/contracts/index.ts"; import { isChordError } from "../../../src/next/data/index.ts"; import { bindProvider, type CheckedArgs, checkArgs, type Value } from "../../../src/next/data/internal.ts"; -import { counted, expectCollected } from "../support.ts"; +import { counted, expectCollected, Server } from "../support.ts"; const Note = obj({ text: str() }); const NoteV2 = obj({ text: str(), tag: str() }); @@ -23,6 +23,7 @@ const NoteV2 = obj({ text: str(), tag: str() }); const NotesV1 = remote("notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(Note) }, diff --git a/packages/chord/test/next/data/errors.test.ts b/packages/chord/test/next/data/errors.test.ts index c5d955a99..bebc4e8e4 100644 --- a/packages/chord/test/next/data/errors.test.ts +++ b/packages/chord/test/next/data/errors.test.ts @@ -78,7 +78,7 @@ describe("protocol table", () => { const pinned = { version: 1, reasons: { - invalid: ["args", "params", "member", "placement", "reentrant", "read_after_bulk", "cycle"], + invalid: ["args", "params", "member", "host_address", "reentrant", "read_after_bulk", "cycle"], contract_mismatch: ["updating"], forbidden: ["revoked"], gone: [], diff --git a/packages/chord/test/next/data/failure.test.ts b/packages/chord/test/next/data/failure.test.ts index a5ddbacf2..b833f4d8a 100644 --- a/packages/chord/test/next/data/failure.test.ts +++ b/packages/chord/test/next/data/failure.test.ts @@ -20,7 +20,7 @@ describe("failure causes", () => { }); it("the kind comes from the stage, never from the error code", () => { - const chord = invalid("placement", "wrong host"); + const chord = invalid("host_address", "wrong host"); expect(openFailure(chord, "open")).toEqual({ kind: "transient", error: chord }); expect(openFailure(broken(chord), "open")).toEqual({ kind: "broken", error: chord }); }); diff --git a/packages/chord/test/next/data/params.test.ts b/packages/chord/test/next/data/params.test.ts index 55b9a8ebc..5848b0378 100644 --- a/packages/chord/test/next/data/params.test.ts +++ b/packages/chord/test/next/data/params.test.ts @@ -5,11 +5,12 @@ import { bool, int, local, oneOf, remote, str } from "../../../src/next/contract import { isChordError } from "../../../src/next/data/index.ts"; import { checkParams, identityKey } from "../../../src/next/data/internal.ts"; import { validatorFor } from "../../../src/next/data/validator.ts"; -import { counted, expectCollected } from "../support.ts"; +import { counted, expectCollected, Server } from "../support.ts"; const Session = remote("session", { version: 1, params: { id: str({ min: 1 }), mode: oneOf("chat", "agent"), page: int({ min: 0 }), open: bool() }, + hostAddress: () => Server(), variants: { owner: {} }, }); diff --git a/packages/chord/test/next/examples/08-remote.ts b/packages/chord/test/next/examples/08-remote.ts index c88d81e15..fb538aecb 100644 --- a/packages/chord/test/next/examples/08-remote.ts +++ b/packages/chord/test/next/examples/08-remote.ts @@ -3,7 +3,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/08-remote.ts import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts"; -import { arr, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { arr, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { memoryLinkPair } from "../../../src/next/link/index.ts"; @@ -17,10 +17,14 @@ type P = { kind: "owner" } | { kind: "guest"; session: string } | { kind: "serve const Note = obj({ text: str(), author: str() }); const PublicNote = obj({ text: str() }); +// The server's host kind: every remote contract names the address of the host serving its objects. +const Server = hostKind("server"); + // A remote contract: one object per (type, params, principal key), in the variant its factory chooses. const Notes = remote("example.notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(obj({ text: str() })) } }, reader: { state: { notes: arr(PublicNote), count: int() } }, @@ -68,8 +72,12 @@ const facet = defineFacet

((host) => { }); }); -// 1. The server: it acts as `server` for its own requests. -const server = createHost

({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) }); +// 1. The server, at its address: it acts as `server` for its own requests. +const server = createHost

({ + address: Server(), + principal: { kind: "server" }, + onError: (error) => console.error("server:", error), +}); await server.load([{ name: "@example/notes", facet }], context); // 2. A client (a tab): no principal of its own. The entry point authenticated it as a guest of S1 and stamps that on diff --git a/packages/chord/test/next/examples/09-calls.ts b/packages/chord/test/next/examples/09-calls.ts index 01dfbbe14..5c099fd5d 100644 --- a/packages/chord/test/next/examples/09-calls.ts +++ b/packages/chord/test/next/examples/09-calls.ts @@ -3,7 +3,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/09-calls.ts import { BACKGROUND_CONTEXT, withCancel } from "../../../src/context/index.ts"; -import { bytes, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { bytes, hostKind, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { isChordError, PluginError } from "../../../src/next/data/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { memoryLinkPair } from "../../../src/next/link/index.ts"; @@ -12,9 +12,13 @@ const context = BACKGROUND_CONTEXT; type P = { kind: "owner" } | { kind: "server" }; +// The server's host kind: a remote contract's `hostAddress` names the host serving its objects. +const Server = hostKind("server"); + const Files = remote("example.files", { version: 1, params: {}, + hostAddress: () => Server(), variants: { owner: { methods: { @@ -58,7 +62,11 @@ const facet = defineFacet

((host) => { })); }); -const server = createHost

({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) }); +const server = createHost

({ + address: Server(), + principal: { kind: "server" }, + onError: (error) => console.error("server:", error), +}); await server.load([{ name: "@example/files", facet }], context); const [up, down] = memoryLinkPair(); server.accept(up, { principal: { kind: "owner" } }, context); diff --git a/packages/chord/test/next/examples/10-drops.ts b/packages/chord/test/next/examples/10-drops.ts index 701961c45..f0ca424f7 100644 --- a/packages/chord/test/next/examples/10-drops.ts +++ b/packages/chord/test/next/examples/10-drops.ts @@ -3,7 +3,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/10-drops.ts import { BACKGROUND_CONTEXT, type Context } from "../../../src/context/index.ts"; -import { arr, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { arr, hostKind, int, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet, type Host } from "../../../src/next/host/index.ts"; import { type Link, memoryLinkPair } from "../../../src/next/link/index.ts"; @@ -12,9 +12,13 @@ const context = BACKGROUND_CONTEXT; type P = { kind: "owner" } | { kind: "server" }; +// The server's host kind: a remote contract's `hostAddress` names the host serving its objects. +const Server = hostKind("server"); + const Notes = remote("example.notes", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { notes: arr(str()) }, @@ -47,7 +51,11 @@ const facet = defineFacet

((host) => { ); }); -const server = createHost

({ principal: { kind: "server" }, onError: (error) => console.error("server:", error) }); +const server = createHost

({ + address: Server(), + principal: { kind: "server" }, + onError: (error) => console.error("server:", error), +}); await server.load([{ name: "@example/notes", facet }], context); const client = createHost

({ onError: (error) => console.error("client:", error) }); await client.load([], context); diff --git a/packages/chord/test/next/examples/12-routes.ts b/packages/chord/test/next/examples/12-routes.ts index 7074bd979..7f6516887 100644 --- a/packages/chord/test/next/examples/12-routes.ts +++ b/packages/chord/test/next/examples/12-routes.ts @@ -1,4 +1,4 @@ -// Routes: host kinds and addresses, a contract's placement, a server routing a session's objects to an in-process +// Routes: host kinds and addresses, a contract's host address, a server routing a session's objects to an in-process // worker (one `resolve` per address, `authorize` per principal), a guest's `ctx.get` on the server reaching the worker // as the guest, and a worker that drops and is resolved again. // Run from packages/chord: @@ -26,7 +26,7 @@ const Session = hostKind("session", { id: str() }); const Notes = remote("example.notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { editor: { state: { notes: arr(str()) }, @@ -36,10 +36,11 @@ const Notes = remote("example.notes", { }, }); -// The lobby lives on the server (no placement): its handler gets notes for whoever calls it. +// The lobby lives on the server: its handler gets notes for whoever calls it. const Lobby = remote("example.lobby", { version: 1, params: {}, + hostAddress: () => Server(), variants: { visitor: { methods: { peek: method(obj({ session: str() }), str()) } } }, }); diff --git a/packages/chord/test/next/examples/13-relay.ts b/packages/chord/test/next/examples/13-relay.ts index 6203d3f52..f132e76f8 100644 --- a/packages/chord/test/next/examples/13-relay.ts +++ b/packages/chord/test/next/examples/13-relay.ts @@ -30,7 +30,7 @@ const SessionInfo = local("example.session-info", { version: 1, params: {}, memb const Notes = remote("example.notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { owner: { state: { info: Init, notes: arr(str()) }, methods: { add: method(obj({ text: str() }), int()) } }, }, diff --git a/packages/chord/test/next/examples/14-flow-control.ts b/packages/chord/test/next/examples/14-flow-control.ts index 4eba6e1ee..184133150 100644 --- a/packages/chord/test/next/examples/14-flow-control.ts +++ b/packages/chord/test/next/examples/14-flow-control.ts @@ -28,7 +28,7 @@ const Reply = local("example.reply", { const View = remote("example.view", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { owner: { state: { reply: obj({ text: str() }) } } }, }); diff --git a/packages/chord/test/next/examples/15-revocation.ts b/packages/chord/test/next/examples/15-revocation.ts index 1a004ffa6..9ee2a3648 100644 --- a/packages/chord/test/next/examples/15-revocation.ts +++ b/packages/chord/test/next/examples/15-revocation.ts @@ -33,7 +33,7 @@ const Session = hostKind("session", { id: str() }); const Notes = remote("example.notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { all: { state: { notes: arr(str()) }, methods: { add: method(obj({ text: str() })), follow: method() } }, }, diff --git a/packages/chord/test/next/examples/16-presence-and-activity.ts b/packages/chord/test/next/examples/16-presence-and-activity.ts index 9091557e0..ba69803cf 100644 --- a/packages/chord/test/next/examples/16-presence-and-activity.ts +++ b/packages/chord/test/next/examples/16-presence-and-activity.ts @@ -6,7 +6,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/16-presence-and-activity.ts import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts"; -import { int, method, remote, str } from "../../../src/next/contracts/index.ts"; +import { hostKind, int, method, remote, str } from "../../../src/next/contracts/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { memoryLinkPair } from "../../../src/next/link/index.ts"; @@ -14,15 +14,19 @@ const context = BACKGROUND_CONTEXT; type P = { kind: "user"; name: string } | { kind: "server" }; +// The server's host kind: a remote contract's `hostAddress` names the host serving its objects. +const Server = hostKind("server"); + /** A shared document view: who else is looking (`viewers`), and a slow method. */ const Doc = remote("example.doc", { version: 1, params: { id: str() }, + hostAddress: () => Server(), variants: { v: { state: { viewers: int() }, methods: { think: method(undefined, int()) } } }, }); // 1. The server serves `Doc`; its factory derives the viewer count from `host.references(Doc)` (on the facet's host). -const server = createHost

({ principal: { kind: "server" }, onError: console.error }); +const server = createHost

({ address: Server(), principal: { kind: "server" }, onError: console.error }); const facet = defineFacet

((host) => { host.serve( Doc, diff --git a/packages/chord/test/next/examples/17-limits-and-compression.ts b/packages/chord/test/next/examples/17-limits-and-compression.ts index 970dce626..da3dbe0a9 100644 --- a/packages/chord/test/next/examples/17-limits-and-compression.ts +++ b/packages/chord/test/next/examples/17-limits-and-compression.ts @@ -9,7 +9,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/17-limits-and-compression.ts import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts"; -import { arr, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { arr, hostKind, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; import { isChordError } from "../../../src/next/data/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet, type Host } from "../../../src/next/host/index.ts"; @@ -29,10 +29,14 @@ const Store = local("example.store", { members: { entries: arr(Entry), append: method(Entry) }, }); +// The server's host kind: a remote contract's `hostAddress` names the host serving its objects. +const Server = hostKind("server"); + /** What a tab holds: the conversation's entries, and appending. */ const Conversation = remote("example.conversation", { version: 1, params: { id: str() }, + hostAddress: () => Server(), variants: { v: { state: { entries: arr(Entry) }, methods: { append: method(Entry) } } }, }); @@ -78,7 +82,12 @@ function counted(link: Link, bytes: { received: number }): Link { * `limits`; the tab's own link to the server has `tabLimits`. */ async function setUp(compression: Compression | undefined, limits?: PartLimits, tabLimits?: PartLimits) { - const server = createHost

({ principal: { kind: "server" }, compression, onError: console.error }); + const server = createHost

({ + address: Server(), + principal: { kind: "server" }, + compression, + onError: console.error, + }); await server.load([{ name: "@example/conversation", facet }], context); const tab = createHost

({ onError: console.error }); await tab.load([], context); @@ -168,6 +177,7 @@ for (const compression of [undefined, nodeCompression]) { // child, would refuse a compressed part. Its 100 KB argument arrives as JSON, within the server's default limits. { const server = createHost

({ + address: Server(), principal: { kind: "server" }, compression: nodeCompression, onError: console.error, diff --git a/packages/chord/test/next/examples/19-jobs.ts b/packages/chord/test/next/examples/19-jobs.ts index 06964dc63..94c9646ca 100644 --- a/packages/chord/test/next/examples/19-jobs.ts +++ b/packages/chord/test/next/examples/19-jobs.ts @@ -6,7 +6,7 @@ // Run from packages/chord: // node --conditions=source --experimental-strip-types test/next/examples/19-jobs.ts import { BACKGROUND_CONTEXT, type Context } from "../../../src/context/index.ts"; -import { arr, int, local, method, obj, remote, type Static, str } from "../../../src/next/contracts/index.ts"; +import { arr, hostKind, int, local, method, obj, remote, type Static, str } from "../../../src/next/contracts/index.ts"; import { defineState } from "../../../src/next/documents/index.ts"; import { createHost, defineFacet, sleep } from "../../../src/next/host/index.ts"; import { defineJob } from "../../../src/next/jobs/index.ts"; @@ -56,10 +56,14 @@ const ChatStore = local("example.chat-store", { }, }); +// The server's host kind: a remote contract's `hostAddress` names the host serving its objects. +const Server = hostKind("server"); + /** Per-principal views: the owner runs shells and sees their output, a guest sees which shells run. */ const Chat = remote("example.chat", { version: 1, params: {}, + hostAddress: () => Server(), variants: { owner: { state: { entries: arr(str()), shells: arr(Shell) }, @@ -170,7 +174,11 @@ const facet = defineFacet

((host) => { }); // 2. A server and two tabs, the owner's and a guest's. -const server = createHost

({ principal: { kind: "server" }, onError: (error) => console.error(error) }); +const server = createHost

({ + address: Server(), + principal: { kind: "server" }, + onError: (error) => console.error(error), +}); await server.load([{ name: "@example/chat", facet }], context); async function tab(principal: P) { const host = createHost

({ onError: (error) => console.error(error) }); diff --git a/packages/chord/test/next/host/activity.test.ts b/packages/chord/test/next/host/activity.test.ts index 405089d6d..cdfa929b7 100644 --- a/packages/chord/test/next/host/activity.test.ts +++ b/packages/chord/test/next/host/activity.test.ts @@ -6,9 +6,14 @@ import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { bool, int, local, method, remote } from "../../../src/next/contracts/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { constant } from "../../../src/next/state/index.ts"; -import { fakeTimers, flushed, framesOf, linked, loaded, Notes, serveNotes, transported } from "../support.ts"; +import { fakeTimers, flushed, framesOf, linked, loaded, Notes, Server, serveNotes, transported } from "../support.ts"; -const Kept = remote("app.kept", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); +const Kept = remote("app.kept", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, +}); describe("host.activity", () => { it("counts holds arriving over links and served here, not this host's own references", async () => { diff --git a/packages/chord/test/next/host/attempt.test.ts b/packages/chord/test/next/host/attempt.test.ts index 4066748e7..d1227bc4a 100644 --- a/packages/chord/test/next/host/attempt.test.ts +++ b/packages/chord/test/next/host/attempt.test.ts @@ -21,7 +21,17 @@ import { TableChanges } from "../../../src/next/store/table-changes.ts"; import { evolveTable } from "../../../src/next/store/table-evolve.ts"; import { TableHandle } from "../../../src/next/store/table-handle.ts"; import { prepareTables } from "../../../src/next/store/table-migration.ts"; -import { failures, fakeTimers, flushed, loaded, memoryStore, metered, rejection, tableOwner } from "../support.ts"; +import { + failures, + fakeTimers, + flushed, + loaded, + memoryStore, + metered, + rejection, + Server, + tableOwner, +} from "../support.ts"; const List = defineState("list", { schema: obj({ items: arr(str()) }), version: 1, initial: () => ({ items: [] }) }); const Page = local("app.page", { version: 1, params: { id: int() }, members: { label: str(), slow: method() } }); @@ -565,6 +575,7 @@ describe("remote variants", () => { const Two = remote("app.two", { version: 1, params: {}, + hostAddress: () => Server(), variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { n: int() } } }, }); const owner = { kind: "owner" } as const; @@ -572,7 +583,11 @@ describe("remote variants", () => { /** A host acting as `owner` whose factory for `Two` returns `implementation`. */ async function two(implementation: unknown) { const errors: HostError[] = []; - const host = createHost({ principal: owner, onError: (error) => errors.push(error) }); + const host = createHost({ + address: Server(), + principal: owner, + onError: (error) => errors.push(error), + }); const facet = defineFacet((h) => h.serve(Two, () => implementation as never)); await host.load([{ name: "@test/app", facet }], context); return { host, errors }; diff --git a/packages/chord/test/next/host/ctx.test.ts b/packages/chord/test/next/host/ctx.test.ts index df259c227..bf4744b76 100644 --- a/packages/chord/test/next/host/ctx.test.ts +++ b/packages/chord/test/next/host/ctx.test.ts @@ -5,7 +5,16 @@ import { withLinkedAbortSignal } from "../../../src/next/host/ctx.ts"; import type { ChordHost } from "../../../src/next/host/host.ts"; import type { Ctx, Host, Reference } from "../../../src/next/host/index.ts"; import { constant } from "../../../src/next/state/index.ts"; -import { flushed, incarnationOf, linked, loaded, rawParent, rejection, type TestPrincipal } from "../support.ts"; +import { + flushed, + incarnationOf, + linked, + loaded, + rawParent, + rejection, + Server, + type TestPrincipal, +} from "../support.ts"; const Item = local("app.item", { version: 1, params: { id: int() }, members: { id: int() } }); const Desk = local("app.desk", { @@ -111,7 +120,12 @@ describe("ctx", () => { }); it("a ctx.get still waiting when its handler settles gives up: its reference is released at once", async () => { - const Far = remote("app.far", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Far = remote("app.far", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const Starter = local("app.starter", { version: 1, params: {}, members: { start: method() } }); let pending: Promise | undefined; const { host } = await loaded((h) => @@ -146,6 +160,7 @@ describe("local handlers act as the host (§4, audit)", () => { const Gate = remote("app.gate", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { methods: { who: method(undefined, str()), through: method(undefined, str()) } } }, }); const who = (ctx: Ctx) => `${ctx.principal?.kind ?? "none"} ${ctx.incarnation}`; diff --git a/packages/chord/test/next/host/host.test.ts b/packages/chord/test/next/host/host.test.ts index 64a291b43..0074d240e 100644 --- a/packages/chord/test/next/host/host.test.ts +++ b/packages/chord/test/next/host/host.test.ts @@ -31,6 +31,8 @@ import { partValue, RawPeer, rejection, + SERVER_ADDRESS, + Server, Session, serveNotes, type TestPrincipal, @@ -482,13 +484,24 @@ describe("getters", () => { forEachCodec(() => { describe("principals and where a reference goes", () => { - const Who = remote("app.who", { version: 1, params: { id: int() }, variants: { v: { state: { who: str() } } } }); - const Outer = remote("app.outer", { version: 1, params: {}, variants: { v: { state: { who: str() } } } }); + const Who = remote("app.who", { + version: 1, + params: { id: int() }, + hostAddress: () => Server(), + variants: { v: { state: { who: str() } } }, + }); + const Outer = remote("app.outer", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { who: str() } } }, + }); const Inner = local("app.inner", { version: 1, params: { id: int() }, members: { who: str() } }); const Desk = local("app.desk", { version: 1, params: {}, members: { who: method(undefined, str()) } }); const Gate = remote("app.gate", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { methods: { who: method(undefined, str()) } } }, }); type P = TestPrincipal; @@ -518,6 +531,12 @@ forEachCodec(() => { await expect(loading).rejects.toThrow("app.who: a host without a principal serves only local types"); }); + it("a host without an address serves no remote type, whatever its principal", async () => { + const host = createHost

({ principal: { kind: "server" }, onError: () => {} }); + const loading = host.load([{ name: "@test/app", facet: defineFacet

((h) => serveAll(h, [])) }], context); + await expect(loading).rejects.toThrow("app.who: a host without an address serves only local types"); + }); + it("ctx.get acts as ctx.principal: a guest's call gets the guest's view; a local handler's acts as the host", async () => { const { server, client } = await linked((h) => serveAll(h, []), { child: { kind: "guest", session: "S1" } }); expect(await (await client.get(Gate, {}, context)).who(context)).toBe("guest"); @@ -533,6 +552,7 @@ forEachCodec(() => { it("identity includes the principal key: equal keys share one object", async () => { const factories: string[] = []; const host = createHost

({ + address: Server(), principal: { kind: "guest", session: "S1" }, principalKey: (principal) => principal.kind, onError: () => {}, @@ -549,6 +569,7 @@ forEachCodec(() => { version: Who.version, fingerprint: Who.fingerprint, params: jsonPart({ id: 1 }), + address: SERVER_ADDRESS, }); await peer.next("bound"); expect(factories).toEqual(["1 guest"]); @@ -562,6 +583,7 @@ forEachCodec(() => { version: Who.version, fingerprint: Who.fingerprint, params: jsonPart({ id: 1 }), + address: SERVER_ADDRESS, }); await owner.next("bound"); expect(factories).toEqual(["1 guest", "1 owner"]); @@ -569,7 +591,12 @@ forEachCodec(() => { it("remote objects with empty params are not activated by load; they live while referenced", async () => { const factories: string[] = []; - const Empty = remote("app.empty", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Empty = remote("app.empty", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const { server } = await linked((h) => h.serve(Empty, (_, principal) => { factories.push(principal.kind); @@ -661,7 +688,7 @@ describe("acquiring: hold and get", () => { const offline = { code: "unavailable", message: "app.svc(1): offline" }; it("invalid params and a local type not served here: at once", async () => { - const host = createHost({ ...noErrors, principal: { kind: "server" } }); + const host = createHost({ address: Server(), ...noErrors, principal: { kind: "server" } }); await host.load([{ name: "@test/app", facet: defineFacet((h) => void serveNotes(h)) }], context); const params = { code: "invalid", reason: "params" }; expect(() => host.hold(Notes, { session: 1 } as never, context)).toThrow("params"); @@ -814,8 +841,8 @@ forEachCodec(() => { return { host, child: new RawPeer(b), parent, errors }; } - /** Sends a hold of `PlacedNotes` (or `type`) for `session`, naming `address` when given. */ - function holdOf(peer: RawPeer, session: string, address?: string, type: string = PlacedNotes.name, hold = 0) { + /** Sends a hold of `PlacedNotes` (or `type`) for `session`, naming the session `address`. */ + function holdOf(peer: RawPeer, session: string, address: string, type: string = PlacedNotes.name, hold = 0) { peer.send({ kind: "hold", hold, @@ -823,13 +850,13 @@ forEachCodec(() => { version: PlacedNotes.version, fingerprint: PlacedNotes.fingerprint, params: jsonPart({ session }), - ...(address === undefined ? {} : { address: { kind: "session", params: { id: address } } }), + address: { kind: "session", params: { id: address } }, }); } const misplaced = { code: "invalid", - reason: "placement", + reason: "host_address", message: "app.placed-notes: placed on session, this host is session|S1", }; @@ -839,7 +866,7 @@ forEachCodec(() => { expect((await child.next("bound")).holds[0]).toMatchObject({ hold: 0 }); }); - it("refuses a hold served here whose params place it elsewhere, whatever address it names: invalid/placement (race 5)", async () => { + it("refuses a hold served here whose params place it elsewhere, whatever address it names: invalid/host_address (race 5)", async () => { const { child } = await worker(); holdOf(child, "S2", "S1"); expect(await child.next("failed")).toMatchObject({ @@ -847,7 +874,7 @@ forEachCodec(() => { cause: { kind: "broken", error: { kind: "chord", error: misplaced } }, }); // A refused hold is kept (a retry starts it again): the next hold takes the next id. - holdOf(child, "S2", undefined, PlacedNotes.name, 1); + holdOf(child, "S2", "S2", PlacedNotes.name, 1); expect(await child.next("failed")).toMatchObject({ hold: 1, cause: { kind: "broken", error: { kind: "chord", error: misplaced } }, @@ -860,7 +887,7 @@ forEachCodec(() => { expect((await child.next("bound")).holds[0]).toMatchObject({ hold: 0 }); }); - it("sends a reference made here whose placement is another host over the parent link, though it serves the type", async () => { + it("sends a reference made here whose host address is another host over the parent link, though it serves the type", async () => { const { host, parent } = await worker({ parent: true }); const elsewhere = host.hold(PlacedNotes, { session: "S2" }, context); const hold = await parent!.next("hold"); @@ -871,7 +898,7 @@ forEachCodec(() => { it("sends a hold it cannot serve or route over the parent link, unparsed", async () => { const { child, parent } = await worker({ parent: true }); - holdOf(child, "S9", undefined, "app.elsewhere"); + holdOf(child, "S9", "S9", "app.elsewhere"); const relayed = await parent!.next("hold"); expect(relayed).toMatchObject({ type: "app.elsewhere" }); expect(partValue(relayed.params)).toEqual({ session: "S9" }); @@ -879,7 +906,7 @@ forEachCodec(() => { it("never sends a hold from the parent back to it: unavailable (race 8)", async () => { const { parent } = await worker({ parent: true }); - holdOf(parent!, "S9", undefined, "app.elsewhere"); + holdOf(parent!, "S9", "S9", "app.elsewhere"); expect(await parent!.next("failed")).toMatchObject({ hold: 0, cause: { @@ -892,25 +919,25 @@ forEachCodec(() => { it("refuses a hold it cannot serve or route without a parent link: unavailable", async () => { const { child } = await worker(); - holdOf(child, "S9", undefined, "app.elsewhere"); + holdOf(child, "S9", "S9", "app.elsewhere"); expect(await child.next("failed")).toMatchObject({ cause: { kind: "broken", error: { kind: "chord", error: { code: "unavailable" } } }, }); }); - it("fails a reference broken with what its placement throws", async () => { + it("fails a reference broken with what its hostAddress throws", async () => { const Throwing = remote("app.throwing", { version: 1, params: {}, - placement: () => { - throw new Error("placement threw"); + hostAddress: () => { + throw new Error("hostAddress threw"); }, variants: { v: { state: { n: int() } } }, }); const client = createHost(noErrors); await client.load([], context); expect(await rejection(client.get(Throwing, {}, context))).toMatchObject({ - message: "app.throwing: placement threw", + message: "app.throwing: hostAddress threw", }); expect(client.hold(Throwing, {}, context).state.value).toMatchObject({ kind: "failed" }); }); diff --git a/packages/chord/test/next/host/member-source.test.ts b/packages/chord/test/next/host/member-source.test.ts index 4a804cdb7..628b84e2c 100644 --- a/packages/chord/test/next/host/member-source.test.ts +++ b/packages/chord/test/next/host/member-source.test.ts @@ -22,6 +22,7 @@ import { loaded, memoryStore, metered, + Server, transported, valuesOf, } from "../support.ts"; @@ -324,7 +325,12 @@ describe("re-binding when a new attempt goes live", () => { describe("subscribers", () => { it("a hold over a link on a remote object re-exposing a local member gets frames again after the local retry", async () => { const Count = local("app.count", { version: 1, params: {}, members: { n: obj({ n: int() }), add: method() } }); - const View = remote("app.view", { version: 1, params: {}, variants: { v: { state: { n: obj({ n: int() }) } } } }); + const View = remote("app.view", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: obj({ n: int() }) } } }, + }); const Counter = defineState("counter", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) }); const selves: Self[] = []; const { server, client } = await linked((h) => { @@ -406,10 +412,11 @@ describe("a member the new attempt's variant lacks", () => { const Two = remote("app.two", { version: 1, params: {}, + hostAddress: () => Server(), variants: { full: { state: { n: int() } }, view: { state: { m: int() } } }, }); const owner = { kind: "owner" } as const; - const host = createHost({ principal: owner, onError: () => {} }); + const host = createHost({ address: Server(), principal: owner, onError: () => {} }); const selves: Self[] = []; const facet = defineFacet((h) => h.serve(Two, (_, _principal, self) => { diff --git a/packages/chord/test/next/host/object.test.ts b/packages/chord/test/next/host/object.test.ts index d492240f0..ab1675fb2 100644 --- a/packages/chord/test/next/host/object.test.ts +++ b/packages/chord/test/next/host/object.test.ts @@ -26,6 +26,8 @@ import { rejection, relayed, restartable, + SERVER_ADDRESS, + Server, Session, StoreRestartable, storeRestartable, @@ -705,14 +707,19 @@ describe("linger", () => { /** Every object attempted in this file, weakly: the objects of the cycles among them. */ const attempted = instancesOf(ChordObject, "attempt"); -/** On the server (no placement): its activation holds `Far` on the session's worker. */ -const Near = remote("app.near", { version: 1, params: { session: str() }, variants: { v: { state: { n: int() } } } }); +/** On the server: its activation holds `Far` on the session's worker. */ +const Near = remote("app.near", { + version: 1, + params: { session: str() }, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, +}); /** On the session's worker: its activation holds `Near` back on the server, or, with `local`, `Middle`. */ const Far = remote("app.far", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { v: { state: { n: int() } } }, }); @@ -720,6 +727,7 @@ const Far = remote("app.far", { const Middle = remote("app.middle", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { v: { state: { n: int() } } }, }); @@ -730,7 +738,7 @@ const Hub = local("app.hub", { version: 1, params: { session: str() }, members: const Loop = remote("app.loop", { version: 1, params: { here: str(), there: str() }, - placement: ({ here }) => Session({ id: here }), + hostAddress: ({ here }) => Session({ id: here }), variants: { v: { state: { n: int() } } }, }); @@ -910,7 +918,15 @@ function farHold(hold: number): Extract { /** A hold of `Near` for S1, as a worker sends it to the server. */ function nearHold(hold: number): Extract { const { name: type, version, fingerprint } = Near; - return { kind: "hold", hold, type, version, fingerprint, params: jsonPart({ session: "S1" }) }; + return { + kind: "hold", + hold, + type, + version, + fingerprint, + params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, + }; } describe("a failed activation's cleanup (audit)", () => { diff --git a/packages/chord/test/next/host/presence.test.ts b/packages/chord/test/next/host/presence.test.ts index 6260f9360..a43b523bf 100644 --- a/packages/chord/test/next/host/presence.test.ts +++ b/packages/chord/test/next/host/presence.test.ts @@ -22,6 +22,8 @@ import { PlacedNotes, RawPeer, relayed, + SERVER_ADDRESS, + Server, serveNotes, type TestPrincipal, testCodec, @@ -84,9 +86,10 @@ describe("host.references", () => { const Shown = remote("app.shown", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { v: { state: { here: int() } } }, }); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load( [ { @@ -130,9 +133,10 @@ describe("host.references", () => { const Viewed = remote("app.viewed", { version: 1, params: { session: str() }, + hostAddress: () => Server(), variants: { v: { state: { count: int(), viewers: int() } } }, }); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); const facet = defineFacet((host) => { serveNotes(host); host.serve(Viewed, ({ session }, _principal, self) => { @@ -151,7 +155,15 @@ describe("host.references", () => { server.accept(end, { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b); const { name: type, version, fingerprint } = Viewed; - peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: jsonPart({ session: "S1" }) }); + peer.send({ + kind: "hold", + hold: 0, + type, + version, + fingerprint, + params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, + }); return peer; }; const drop = { armed: false }; @@ -176,9 +188,10 @@ describe("host.references", () => { const Viewed = remote("app.viewed", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { viewers: int() } } }, }); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); const facet = defineFacet((host) => { host.serve( Viewed, @@ -195,13 +208,13 @@ describe("host.references", () => { server.accept(closingOnSend(a, drop), { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b); const { name: type, version, fingerprint } = Viewed; - peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}" }); + peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); await peer.next("bound"); peer.send({ kind: "ack" }); await transported(); // The second hold's presence entry changes the first's state: sending it closes the link. drop.armed = true; - peer.send({ kind: "hold", hold: 1, type, version, fingerprint, params: "{}" }); + peer.send({ kind: "hold", hold: 1, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); await transported(); expect(peer.closed).toBe(true); expect(server.references(Viewed).value).toEqual([]); @@ -210,8 +223,13 @@ describe("host.references", () => { }); it("completes a release's bookkeeping before its departure publishes: a link closing then leaves no lingering", async () => { - const Viewed = remote("app.viewed", { version: 1, params: {}, variants: { v: { state: { viewers: int() } } } }); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const Viewed = remote("app.viewed", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { viewers: int() } } }, + }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); const facet = defineFacet((host) => { host.serve( Viewed, @@ -229,7 +247,7 @@ describe("host.references", () => { const peer = new RawPeer(b); const { name: type, version, fingerprint } = Viewed; for (const hold of [0, 1]) { - peer.send({ kind: "hold", hold, type, version, fingerprint, params: "{}" }); + peer.send({ kind: "hold", hold, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); await peer.next("bound"); peer.send({ kind: "ack" }); } @@ -248,6 +266,7 @@ describe("host.references", () => { it("removes a bulk departure with one list and one splice op per entry, in one frame (a revoke of many holds)", async () => { const server = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy: (principal) => (principal.kind === "guest" ? [{ session: principal.session }] : []), onError: () => {}, @@ -258,7 +277,15 @@ describe("host.references", () => { const peer = new RawPeer(b); const { name: type, version, fingerprint } = Notes; for (let hold = 0; hold < 4; hold++) { - peer.send({ kind: "hold", hold, type, version, fingerprint, params: jsonPart({ session: "S1" }) }); + peer.send({ + kind: "hold", + hold, + type, + version, + fingerprint, + params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, + }); } await transported(); const references = server.references(Notes); @@ -285,7 +312,12 @@ describe("host.references", () => { }); it("has no entries for lingering objects", async () => { - const Kept = remote("app.kept", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Kept = remote("app.kept", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const { server, client } = await linked((h) => h.serve(Kept, () => ({ variant: "v", members: { n: constant(1) } }), { linger: 60_000 }), ); diff --git a/packages/chord/test/next/host/reference.test.ts b/packages/chord/test/next/host/reference.test.ts index adeeb7288..2f2375b1c 100644 --- a/packages/chord/test/next/host/reference.test.ts +++ b/packages/chord/test/next/host/reference.test.ts @@ -29,6 +29,7 @@ import { memoryStore, Notes, rejection, + Server, settledHeap, } from "../support.ts"; @@ -338,12 +339,13 @@ describe("remote references", () => { const Two = remote("app.two", { version: 1, params: {}, + hostAddress: () => Server(), variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } }, }); const Holder = local("app.holder", { version: 1, params: {}, members: { n: int() } }); const seen: unknown[] = []; const owner = { kind: "owner" } as const; - const host = createHost({ principal: owner, onError: () => {} }); + const host = createHost({ address: Server(), principal: owner, onError: () => {} }); const facet = defineFacet((h) => { h.serve(Two, () => ({ variant: "view", members: { m: constant(1) } })); h.serve(Holder, (_, self) => { @@ -369,6 +371,7 @@ describe("remote reference types", () => { const Two = remote("app.two", { version: 1, params: {}, + hostAddress: () => Server(), variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } }, }); type Ref = Reference; @@ -384,6 +387,7 @@ describe("remote reference types", () => { const Two = remote("app.two", { version: 1, params: {}, + hostAddress: () => Server(), variants: { full: { state: { n: int() }, methods: { bump: method() } }, view: { state: { m: int() } } }, }); expectTypeOf["variant"]>().toEqualTypeOf<"full" | "view" | undefined>(); diff --git a/packages/chord/test/next/host/revocation.test.ts b/packages/chord/test/next/host/revocation.test.ts index a80fba200..ed5bab268 100644 --- a/packages/chord/test/next/host/revocation.test.ts +++ b/packages/chord/test/next/host/revocation.test.ts @@ -19,6 +19,8 @@ import { rawParent, rawWorkers, relayed, + SERVER_ADDRESS, + Server, Session, serveNotes, type TestPrincipal, @@ -50,9 +52,10 @@ const Barrier = local("app.barrier", { version: 1, params: {}, members: { n: int /** A guest's variant: its `wait` method's index. */ const { wait } = wireNames(Notes, "reader").methods as { wait: number }; -/** A worker of session S1 serving `Notes` (as `serveNotes`), its parent a raw peer whose holds name their principal. */ +/** A worker of session S1 serving `Notes` (as `serveNotes`, at the address `Notes` names), its parent a raw peer whose holds name their principal. */ async function worker(setup: (host: FacetHost) => void = (h) => void serveNotes(h)) { const host = createHost({ + address: Server(), principal: { kind: "worker", session: "S1" }, revokedBy, onError: () => {}, @@ -71,6 +74,7 @@ function holdAs(peer: RawPeer, hold: number, principal: TestPrincipal, Type: typ version: Type.version, fingerprint: Type.fingerprint, params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, principal, }); } @@ -79,6 +83,7 @@ describe("revocation keys", () => { it("are computed once per principal object, compared as canonical JSON", async () => { let calls = 0; const host = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy: () => { calls++; @@ -103,6 +108,7 @@ describe("revocation keys", () => { it("keep no principal alive (a WeakMap keyed by the principal the host cached them for)", async () => { const cached: WeakRef[] = []; const host = createHost({ + address: Server(), principal: { kind: "worker", session: "S1" }, revokedBy: (principal) => { // The principal decoded on this host, the cache's key: not the sender's object. @@ -172,7 +178,12 @@ forEachCodec(() => { }); it("abandons an activation in progress (race 1): its open's context is aborted, its holders get gone", async () => { - const Slow = remote("app.slow", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Slow = remote("app.slow", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const aborted: unknown[] = []; const { host, parent } = await worker((h) => h.serve(Slow, (_, _principal, self) => { @@ -196,6 +207,7 @@ forEachCodec(() => { version: Slow.version, fingerprint: Slow.fingerprint, params: "{}", + address: SERVER_ADDRESS, principal: S1, }); await transported(); @@ -217,6 +229,7 @@ forEachCodec(() => { // A worker connects before it loads (W6); a load can wait for an activation (the start barrier). const opened = Promise.withResolvers(); const host = createHost({ + address: Server(), principal: { kind: "worker", session: "S1" }, revokedBy, onError: () => {}, @@ -313,6 +326,7 @@ forEachCodec(() => { describe("links and revoke messages", () => { it("closes the child links whose principal it revokes, never the parent link", async () => { const host = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy, onError: () => {}, @@ -336,6 +350,7 @@ forEachCodec(() => { it("ignores a revoke from a child link (race 6)", async () => { const host = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy, onError: () => {}, diff --git a/packages/chord/test/next/host/route.test.ts b/packages/chord/test/next/host/route.test.ts index eb3d6413f..756ae9429 100644 --- a/packages/chord/test/next/host/route.test.ts +++ b/packages/chord/test/next/host/route.test.ts @@ -12,6 +12,7 @@ import { PlacedNotes, rejection, relayed, + Server, Session, serveNotes, type TestPrincipal, @@ -101,18 +102,18 @@ forEachCodec(() => { }); }); - it("fails a hold broken invalid/placement for an address whose params do not fit the kind", async () => { + it("fails a hold broken invalid/host_address for an address whose params do not fit the kind", async () => { const Misplaced = remote("app.misplaced", { version: 1, params: { n: int() }, - // An address of the right kind with params of the wrong type, as a buggy placement could compute. - placement: ({ n }) => ({ kind: "session", params: { id: n }, key: "" }) as never, + // An address of the right kind with params of the wrong type, as a buggy `hostAddress` could compute. + hostAddress: ({ n }) => ({ kind: "session", params: { id: n }, key: "" }) as never, variants: { v: { state: { n: int() } } }, }); const { tab, resolves } = await relayed(); expect(await rejection(tab.get(Misplaced, { n: 1 }, context))).toMatchObject({ code: "invalid", - reason: "placement", + reason: "host_address", }); expect(resolves).toEqual([]); }); @@ -322,11 +323,16 @@ forEachCodec(() => { const Who = remote("app.who", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { methods: { app: method(undefined, str()) } } }, }); const resolved: (string | undefined)[] = []; let worker: Host | undefined; - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: () => {}, + }); server.route( Session, { diff --git a/packages/chord/test/next/host/routines.test.ts b/packages/chord/test/next/host/routines.test.ts index e3bb69a6e..17dfe5eb1 100644 --- a/packages/chord/test/next/host/routines.test.ts +++ b/packages/chord/test/next/host/routines.test.ts @@ -36,6 +36,7 @@ import { loaded, Notes, rawParent, + Server, serveNotes, type TestPrincipal, transported, @@ -789,9 +790,10 @@ describe("on", () => { const Halves = remote("app.halves", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { half: int() }, methods: { set: method(int()) } } }, }); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); const serve = defineFacet((h) => h.serve(Halves, (_, _principal, self) => { const doc = self.state(Doc); @@ -844,7 +846,7 @@ describe("on", () => { }); it("follows ref.state: a reference over a link going loading and live again", async () => { - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load([{ name: "@test/app", facet: defineFacet((h) => void serveNotes(h)) }], context); const Viewer = local("app.viewer", { version: 1, params: { id: int() }, members: {} }); const states: string[] = []; @@ -1069,7 +1071,12 @@ describe("routines and the end", () => { }); it("act as a remote object's principal", async () => { - const Watcher = remote("app.watcher", { version: 1, params: {}, variants: { v: { state: {} } } }); + const Watcher = remote("app.watcher", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: {} } }, + }); const seen: unknown[] = []; const { client } = await linked( (h) => diff --git a/packages/chord/test/next/jobs/job.test.ts b/packages/chord/test/next/jobs/job.test.ts index 4fd9aadcb..d82d82b3f 100644 --- a/packages/chord/test/next/jobs/job.test.ts +++ b/packages/chord/test/next/jobs/job.test.ts @@ -34,6 +34,7 @@ import { loaded, metered, rejection, + Server, type TestPrincipal, transported, } from "../support.ts"; @@ -1057,11 +1058,12 @@ describe("keep-alive", () => { const View = remote("app.view", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { running: arr(unknown()) } } }, }); let job: JobHandle<{ text: string }, string, undefined> | undefined; let ran = false; - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); const facet = defineFacet((h) => h.serve(View, (_, _principal, self) => { job = self.job(Echo, { @@ -1157,13 +1159,19 @@ describe("keep-alive", () => { describe("object end", () => { type Revocation = { session: string }; - const Jobs = remote("app.jobs", { version: 1, params: {}, variants: { v: { state: {} } } }); + const Jobs = remote("app.jobs", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: {} } }, + }); it("revocation (R11): runs' signals fire with the end's error, the drain waits for them, no finish", async () => { let job: JobHandle<{ text: string }, string, undefined> | undefined; const finished: string[] = []; let reason: unknown; const server = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy: (p) => (p.kind === "guest" ? [{ session: p.session }] : []), onError: () => {}, @@ -1209,6 +1217,7 @@ describe("object end", () => { const gate = Promise.withResolvers(); const events: string[] = []; const server = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy: (p) => (p.kind === "guest" ? [{ session: p.session }] : []), onError: () => {}, diff --git a/packages/chord/test/next/link/channel.test.ts b/packages/chord/test/next/link/channel.test.ts index f5abbe8eb..4179830af 100644 --- a/packages/chord/test/next/link/channel.test.ts +++ b/packages/chord/test/next/link/channel.test.ts @@ -20,11 +20,18 @@ import { RawPeer, rawLinked, rawProvider, + SERVER_ADDRESS, + Server, testCodec, transported, } from "../support.ts"; -const Thing = remote("app.thing", { version: 1, params: {}, variants: { only: { state: { n: int() } } } }); +const Thing = remote("app.thing", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { only: { state: { n: int() } } }, +}); const hold = { kind: "hold", @@ -33,6 +40,7 @@ const hold = { version: 1, fingerprint: "0000000000000000", params: "{}", + address: SERVER_ADDRESS, } as const; const problems = (errors: readonly HostError[]) => diff --git a/packages/chord/test/next/link/message.test.ts b/packages/chord/test/next/link/message.test.ts index 6adf76b4d..968612a2a 100644 --- a/packages/chord/test/next/link/message.test.ts +++ b/packages/chord/test/next/link/message.test.ts @@ -21,12 +21,23 @@ function partText(part: Part): Part { } const incarnation = "0190f5a2-7c3e-7d4a-9b1c-2d3e4f5a6b7c"; + +/** The address a hold names: `Server()` on the wire. */ +const server = { kind: "server", params: {} }; const error = { kind: "chord", error: { code: "released", message: "gone" } } as const; /** One message of every kind, with the optional fields both present and absent; parts are JSON text. */ const EVERY_KIND: Message[] = [ { kind: "hello", protocol: 1, incarnation, calls: 256 }, - { kind: "hold", hold: 300, type: "app.notes", version: 2, fingerprint: "0123456789abcdef", params: '{"id":1}' }, + { + kind: "hold", + hold: 300, + type: "app.notes", + version: 2, + fingerprint: "0123456789abcdef", + params: '{"id":1}', + address: server, + }, { kind: "hold", hold: 1, @@ -45,6 +56,7 @@ const EVERY_KIND: Message[] = [ version: 1, fingerprint: "0123456789abcdef", params: "{}", + address: server, incarnation, }, { kind: "release", hold: 0 }, @@ -106,7 +118,16 @@ const EVERY_KIND: Message[] = [ { kind: "ack" }, { kind: "ack", origin: 1 }, { kind: "closed", origin: 300 }, - { kind: "hold", origin: 2, hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}" }, + { + kind: "hold", + origin: 2, + hold: 0, + type: "t", + version: 1, + fingerprint: "0123456789abcdef", + params: "{}", + address: server, + }, { kind: "release", origin: 2, hold: 3 }, { kind: "call", origin: 2, call: 1, hold: 3, method: 0, args: "[1]" }, { kind: "abort", origin: 2, call: 1 }, @@ -130,10 +151,20 @@ const EVERY_KIND: Message[] = [ version: 1, fingerprint: "0123456789abcdef", params: "{}", + address: server, chain: [incarnation, incarnation], limit: 65_536, }, - { kind: "hold", hold: 4, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}", limit: 0 }, + { + kind: "hold", + hold: 4, + type: "t", + version: 1, + fingerprint: "0123456789abcdef", + params: "{}", + address: server, + limit: 0, + }, { kind: "revoke", revocation: { grant: "g1" } }, { kind: "ping" }, { kind: "pong" }, @@ -181,7 +212,15 @@ forEachCodec(() => { const codec = testCodec(); const deflated = () => new DeflatedPart(new Uint8Array([0, 1, 2, 255])); const messages: Message[] = [ - { kind: "hold", hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: deflated() }, + { + kind: "hold", + hold: 0, + type: "t", + version: 1, + fingerprint: "0123456789abcdef", + params: deflated(), + address: server, + }, { kind: "call", call: 0, hold: 0, method: 0, args: deflated() }, { kind: "result", call: 0, value: deflated(), segments: [new Uint8Array([7])] }, { @@ -253,6 +292,7 @@ forEachCodec(() => { version: 1, fingerprint: "0123456789abcdef", params: jsonPart({ "\uFEFFkey": "\uFEFFvalue" }), + address: server, }; const decoded = codec.decode(codec.encode(message)) as Extract; expect(plain(decoded)).toEqual(message); @@ -283,9 +323,10 @@ describe("the binary codec", () => { version: 1, fingerprint: "0123456789abcdef", params: "{}", + address: server, }); - // kind, id, type, version, fingerprint, params, the optional fields' flags - expect(hold).toHaveLength(1 + 1 + 2 + 1 + 8 + 3 + 1); + // kind, id, type, version, fingerprint, params, the address's kind and params, the optional fields' flags + expect(hold).toHaveLength(1 + 1 + 2 + 1 + 8 + 3 + 7 + 3 + 1); expect([...binaryCodec.encode({ kind: "ack" })]).toEqual([13]); // An origin: the kind byte's high bit, then the origin as a varint. expect([...binaryCodec.encode({ kind: "ack", origin: 300 })]).toEqual([13 | 0x80, 0xac, 0x02]); @@ -345,6 +386,7 @@ describe("the binary codec", () => { version: 1, fingerprint: "0123456789abcdef", params: "{}", + address: server, }); const forged = hold.slice(); forged[forged.length - 1] = 32; @@ -446,10 +488,12 @@ describe("the text codec", () => { version: 1, fingerprint: "0123456789abcdef", params: "{}", + address: server, } as const; - expect(text(hold)).toBe('["H",0,"t",1,"0123456789abcdef",[0,{}]]'); - expect(text({ ...hold, incarnation })).toBe(`["H",0,"t",1,"0123456789abcdef",[0,{}],null,null,"${incarnation}"]`); - expect(text({ ...hold, limit: 5 })).toBe('["H",0,"t",1,"0123456789abcdef",[0,{}],null,null,null,null,5]'); + const head = '["H",0,"t",1,"0123456789abcdef",[0,{}],["server",{}]'; + expect(text(hold)).toBe(`${head}]`); + expect(text({ ...hold, incarnation })).toBe(`${head},null,"${incarnation}"]`); + expect(text({ ...hold, limit: 5 })).toBe(`${head},null,null,null,5]`); }); it("puts byte segments after the tuple and a newline, their lengths the tuple's last field", () => { @@ -474,6 +518,15 @@ describe("the text codec", () => { expect(() => textCodec.decode(new TextEncoder().encode('["@",1,["@",2,["a"]]]'))).toThrow("wraps one message"); expect(() => textCodec.decode(new TextEncoder().encode('["@",1]'))).toThrow("wraps one message"); }); + + it("refuses a hold without its address: every hold names one", () => { + const decode = (tuple: string) => textCodec.decode(new TextEncoder().encode(tuple)); + expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}]]')).toThrow('a "H" message with 5 fields'); + expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],null]')).toThrow("expected a list"); + expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],["server"]]')).toThrow("an address is a pair"); + const hold = { kind: "hold", hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}" }; + expect(() => checkMessage(hold, context)).toThrow("message schema"); + }); }); describe("describeMessage", () => { @@ -534,6 +587,7 @@ describe("the binary codec's short strings", () => { version: 1, fingerprint: "0123456789abcdef", params: jsonPart(type), + address: server, }; expect(plain(binaryCodec.decode(binaryCodec.encode(message)))).toEqual(message); }); diff --git a/packages/chord/test/next/link/websocket.test.ts b/packages/chord/test/next/link/websocket.test.ts index 821c1468e..c67212b48 100644 --- a/packages/chord/test/next/link/websocket.test.ts +++ b/packages/chord/test/next/link/websocket.test.ts @@ -4,7 +4,7 @@ import { WebSocket, WebSocketServer } from "ws"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { createHost, defineFacet } from "../../../src/next/host/index.ts"; import { type WebSocketLike, webSocketLink } from "../../../src/next/link/index.ts"; -import { Notes, serveNotes, type TestPrincipal, transported } from "../support.ts"; +import { Notes, Server, serveNotes, type TestPrincipal, transported } from "../support.ts"; /** A socket playing a WebSocket by hand. */ class FakeSocket implements WebSocketLike { @@ -93,7 +93,7 @@ describe("WebSocket links over a fake socket", () => { /** A `ws` server on a free loopback port whose connections a server host accepts, and a URL to it. */ async function loopback(options: { readonly maxPayload?: number } = {}) { - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load([{ name: "@test/app", facet: defineFacet((h) => void serveNotes(h)) }], context); const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", maxPayload: options.maxPayload }); await new Promise((resolve) => wss.once("listening", resolve)); diff --git a/packages/chord/test/next/remote/consumer.test.ts b/packages/chord/test/next/remote/consumer.test.ts index 32e4fb839..5f7005a68 100644 --- a/packages/chord/test/next/remote/consumer.test.ts +++ b/packages/chord/test/next/remote/consumer.test.ts @@ -23,6 +23,8 @@ import { rawParent, rawProvider, rejection, + SERVER_ADDRESS, + Server, serveNotes, testCodec, transported, @@ -287,6 +289,7 @@ forEachCodec(() => { version: Notes.version, fingerprint: Notes.fingerprint, params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, }); const [bound] = (await peer.next("bound")).holds; expect(bound!.variant).toBe(EDITOR.variant); @@ -415,6 +418,7 @@ forEachCodec(() => { const Echo = remote("app.echo", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { methods: { echo: method(map(int()), map(int())) } } }, }); const { client } = await linked((h) => diff --git a/packages/chord/test/next/remote/hold.test.ts b/packages/chord/test/next/remote/hold.test.ts index ab1c07b8a..03d27f74c 100644 --- a/packages/chord/test/next/remote/hold.test.ts +++ b/packages/chord/test/next/remote/hold.test.ts @@ -20,6 +20,8 @@ import { linked, RawPeer, rawLinked, + SERVER_ADDRESS, + Server, type TestPrincipal, testCodec, transported, @@ -50,6 +52,7 @@ const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () const Halves = remote("app.halves", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { n: obj({ n: int() }), half: int() }, methods: { set: method(int()), wait: method() } } }, }); @@ -91,18 +94,24 @@ async function halves() { function hold(peer: RawPeer, id: number, Type: { name: string; version: number; fingerprint: string }) { const { name: type, version, fingerprint } = Type; - peer.send({ kind: "hold", hold: id, type, version, fingerprint, params: "{}" }); + peer.send({ kind: "hold", hold: id, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); } /** The provider object behind a hold, reached through the host's own lookup (to end it by hand). */ function providerObject(server: unknown) { const host = server as ChordHost; const { name: type, version, fingerprint } = Halves; - return host.serving({ type, version, fingerprint, params: "{}" }, {}, { kind: "owner" }, context); + const request = { type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }; + return host.serving(request, {}, { kind: "owner" }, context); } /** A remote type whose activation never completes: its open waits for its abort. */ -const Pending = remote("app.pending", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); +const Pending = remote("app.pending", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, +}); function servePending(h: Parameters[0]>[0]) { h.serve(Pending, (_, _principal, self) => { @@ -221,7 +230,12 @@ forEachCodec(() => { fakeTimers(); it("a new hold of an object failed transiently is sent the failure at once", async () => { - const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Flaky = remote("app.flaky", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const { peer } = await rawLinked((h) => h.serve(Flaky, (_, _principal, self) => { self.open( @@ -309,6 +323,7 @@ forEachCodec(() => { const Slow = local("app.slow", { version: 1, params: {}, members: { n: int() } }); const { server, refs } = await (async () => { const server = createHost({ + address: Server(), principal: { kind: "server" }, readyTimeout: 2_000, onError: () => {}, @@ -343,7 +358,12 @@ forEachCodec(() => { }); /** A remote type whose open throws `offline` (broken when `failing.broken`) while `failing.left` is above 0. */ -const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); +const Flaky = remote("app.flaky", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, +}); function serveFlaky(h: Parameters[0]>[0], failing: { left: number; broken: boolean }) { h.serve(Flaky, (_, _principal, self) => { @@ -441,11 +461,13 @@ forEachCodec(() => { const Tally = remote("app.tally", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { n: obj({ n: int() }) }, methods: { bump: method() } } }, }); const Fresh = remote("app.fresh", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { n: obj({ n: int() }) }, methods: { bump: method() } } }, }); @@ -521,6 +543,7 @@ forEachCodec(() => { const Retiring = remote("app.retiring", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { doc: obj({ n: int() }), big: bool(), t: int() }, @@ -536,7 +559,7 @@ const retiredAll = [RETIRING.state.doc!, RETIRING.state.big!, RETIRING.state.t!] /** A server serving `Retiring` to a raw peer that acks only when told; `call` calls a method of hold 0. */ async function retiring() { - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load( [ { @@ -583,7 +606,7 @@ async function retiring() { expect((await peer.next("result")).call).toBe(id); }; const { name: type, version, fingerprint } = Retiring; - peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}" }); + peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); await peer.next("bound"); peer.ack(); return { peer, call }; diff --git a/packages/chord/test/next/remote/host-link.test.ts b/packages/chord/test/next/remote/host-link.test.ts index 7eb0a8551..c5205ea12 100644 --- a/packages/chord/test/next/remote/host-link.test.ts +++ b/packages/chord/test/next/remote/host-link.test.ts @@ -22,6 +22,7 @@ import { rawWorkers, rejection, relayed, + Server, Session, serveNotes, type TestPrincipal, @@ -33,6 +34,7 @@ import { const ServerWho = remote("app.server-who", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { who: str() }, methods: { echo: method(str(), str()) } } }, }); @@ -40,7 +42,7 @@ const ServerWho = remote("app.server-who", { const Asker = remote("app.asker", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { v: { state: { who: str() }, methods: { ask: method(str(), str()) } } }, }); @@ -48,7 +50,7 @@ const Asker = remote("app.asker", { const Neighbour = remote("app.neighbour", { version: 1, params: { session: str(), other: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: { v: { state: { count: int() } } }, }); @@ -141,6 +143,7 @@ forEachCodec(() => { const Gateway = remote("app.gateway", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { n: int() }, methods: { open: method(str(), int()) } } }, }); // With params: not activated by `load`, which would wait for the raw worker. @@ -342,6 +345,7 @@ async function compressing(options: { }) { const errors: HostError[] = []; const server = createHost({ + address: Server(), principal: { kind: "server" }, compression: options.server, onError: (error) => errors.push(error), diff --git a/packages/chord/test/next/remote/provider.test.ts b/packages/chord/test/next/remote/provider.test.ts index b1be51b9e..a93e54c62 100644 --- a/packages/chord/test/next/remote/provider.test.ts +++ b/packages/chord/test/next/remote/provider.test.ts @@ -21,6 +21,8 @@ import { RAW_PEER, RawPeer, rawLinked, + SERVER_ADDRESS, + Server, serveNotes, type TestPrincipal, testCodec, @@ -39,7 +41,16 @@ function hold( ) { const { name: type, version, fingerprint } = { ...Type, ...override }; const from = origin === undefined ? {} : { origin }; - peer.send({ kind: "hold", ...from, hold: id, type, version, fingerprint, params: jsonPart(params) }); + peer.send({ + kind: "hold", + ...from, + hold: id, + type, + version, + fingerprint, + params: jsonPart(params), + address: SERVER_ADDRESS, + }); } /** Sends a call of the method at index `method` of the bound variant, with `args` (none when left out). */ @@ -50,7 +61,12 @@ function call(peer: RawPeer, id: number, holdId: number, method: number, args?: } /** A remote type kept per id. */ -const Kept = remote("app.kept", { version: 1, params: { id: int() }, variants: { v: { state: { n: int() } } } }); +const Kept = remote("app.kept", { + version: 1, + params: { id: int() }, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, +}); /** The wire's indexes of `Notes`'s reader variant. */ const READER = wireNames(Notes, "reader"); @@ -120,7 +136,11 @@ forEachCodec(() => { }); it("arriving before load resolved wait for it", async () => { - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: () => {}, + }); const [a, b] = memoryLinkPair(); server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b); @@ -177,7 +197,12 @@ forEachCodec(() => { }); it("before bound are refused not_ready (race 3)", async () => { - const Slow = remote("app.slow", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Slow = remote("app.slow", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const { peer } = await rawLinked((h) => h.serve(Slow, (_, _principal, self) => { self.open( @@ -333,7 +358,11 @@ forEachCodec(() => { it("a hold released while it waits for load takes nothing once load resolves", async () => { const events: string[] = []; - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: () => {}, + }); const [a, b] = memoryLinkPair(); server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b); @@ -354,6 +383,7 @@ forEachCodec(() => { const Slow = remote("app.slow", { version: 1, params: { id: int() }, + hostAddress: () => Server(), variants: { v: { state: { n: int() } } }, }); const aborted: string[] = []; @@ -381,6 +411,7 @@ forEachCodec(() => { const Closing = remote("app.closing", { version: 1, params: { id: int() }, + hostAddress: () => Server(), variants: { v: { state: { n: int() } } }, }); const events: string[] = []; @@ -429,6 +460,7 @@ forEachCodec(() => { const Counter = remote("app.counter", { version: 1, params: { id: int() }, + hostAddress: () => Server(), variants: { v: { state: { n: int(), seen: int() }, methods: { bump: method() } } }, }); const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) }); @@ -488,6 +520,7 @@ forEachCodec(() => { const Grid = remote("app.grid", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { grid: obj({ cells: map(int()) }), one: int() }, @@ -520,7 +553,7 @@ function serveGrid(h: FacetHost): void { /** A server serving `Notes` and `Grid` to a raw peer that acks only when told, the link's context counting. */ async function slowConsumer() { const counting = await metered(); - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load( [ { @@ -729,7 +762,12 @@ forEachCodec(() => { }); it("carry constants and failed members, and an empty variant's binding, in a first bound (race 24)", async () => { - const Empty = remote("app.empty", { version: 1, params: {}, variants: { v: {} } }); + const Empty = remote("app.empty", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: {} }, + }); const N = defineState("n", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) }); const { peer } = await rawLinked((h) => { h.serve(Empty, () => ({ variant: "v", members: {} })); @@ -836,6 +874,7 @@ forEachCodec(() => { const Halves = remote("app.halves", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { one: int(), half: int() }, methods: { set: method(int()) } } }, }); @@ -968,7 +1007,15 @@ forEachCodec(() => { /** A hold of `Notes` for S1 as a message. */ function notesHold(id: number): Extract { const { name: type, version, fingerprint } = Notes; - return { kind: "hold", hold: id, type, version, fingerprint, params: jsonPart({ session: "S1" }) }; + return { + kind: "hold", + hold: id, + type, + version, + fingerprint, + params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, + }; } const UTF8 = new TextEncoder(); diff --git a/packages/chord/test/next/remote/relay.test.ts b/packages/chord/test/next/remote/relay.test.ts index 701b2adaf..73d5c3b62 100644 --- a/packages/chord/test/next/remote/relay.test.ts +++ b/packages/chord/test/next/remote/relay.test.ts @@ -21,6 +21,7 @@ import { rawWorkers, rejection, relayed, + Server, serveNotes, type TestPrincipal, testCodec, @@ -379,7 +380,11 @@ forEachCodec(() => { describe("a parent link that is down (audit)", () => { it("holds a hold arriving meanwhile on the parent path: it waits loading and binds on the next parent link", async () => { - const server = createHost({ principal: { kind: "server" }, onError: () => {} }); + const server = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: () => {}, + }); await server.load( [{ name: "@test/app", facet: defineFacet((h) => void serveNotes(h)) }], context, diff --git a/packages/chord/test/next/remote/remote-object.test.ts b/packages/chord/test/next/remote/remote-object.test.ts index 03baaa01c..eeb3fc7a9 100644 --- a/packages/chord/test/next/remote/remote-object.test.ts +++ b/packages/chord/test/next/remote/remote-object.test.ts @@ -35,6 +35,7 @@ import { rawParent, rawProvider, rejection, + Server, serveNotes, type TestPrincipal, testCodec, @@ -48,7 +49,12 @@ const Counter = defineState("counter", { schema: obj({ n: int() }), version: 1, forEachCodec(() => { describe("a hold over the parent link", () => { it("reject a broken failure at once", async () => { - const Broken = remote("app.broken", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Broken = remote("app.broken", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const { client } = await linked((h) => h.serve(Broken, () => { throw new Error("factory threw"); @@ -197,6 +203,7 @@ forEachCodec(() => { const Flaky = remote("app.flaky", { version: 1, params: { id: int() }, + hostAddress: () => Server(), variants: { v: { state: { n: int() } } }, }); const opens: string[] = []; @@ -250,6 +257,7 @@ forEachCodec(() => { const Halves = remote("app.halves", { version: 1, params: {}, + hostAddress: () => Server(), variants: { v: { state: { half: int() }, methods: { set: method(int()) } } }, }); const { server, client } = await linked((h) => @@ -363,7 +371,12 @@ forEachCodec(() => { fakeTimers(); it("fails at once with the real cause when its load holds it (no wait for a timeout)", async () => { - const Flaky = remote("app.flaky", { version: 1, params: {}, variants: { v: { state: { n: int() } } } }); + const Flaky = remote("app.flaky", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { v: { state: { n: int() } } }, + }); const View = local("app.view", { version: 1, params: {}, members: { n: int() } }); const { server, client: first } = await linked((h) => h.serve(Flaky, (_, _principal, self) => { @@ -406,6 +419,7 @@ forEachCodec(() => { const Shifting = remote("app.shifting", { version: 1, params: {}, + hostAddress: () => Server(), variants: { a: { state: { x: int(), y: int() }, methods: { m: method() } }, b: { state: { y: int(), z: int() } }, @@ -592,10 +606,15 @@ forEachCodec(() => { const NotesV2 = remote("app.notes", { version: 2, params: { session: str() }, + hostAddress: () => Server(), variants: { editor: { state: { count: int() } } }, }); const { server, client, links } = await linked((h) => void serveNotes(h)); - const upgraded = createHost({ principal: { kind: "server" }, onError: () => {} }); + const upgraded = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: () => {}, + }); const facet = defineFacet((h) => h.serve(NotesV2, () => ({ variant: "editor", members: { count: constant(0) } })), ); @@ -644,6 +663,7 @@ forEachCodec(() => { const Flaky = remote("app.flaky", { version: 1, params: {}, + hostAddress: () => Server(), variants: { only: { state: {}, methods: { ping: method() } } }, }); let factories = 0; diff --git a/packages/chord/test/next/support.ts b/packages/chord/test/next/support.ts index 9796d534f..06fb043ac 100644 --- a/packages/chord/test/next/support.ts +++ b/packages/chord/test/next/support.ts @@ -401,6 +401,7 @@ export async function linked( const errors = { server: [] as HostError[], client: [] as HostError[] }; const { telemetry, readyTimeout } = options; const server = createHost({ + address: Server(), principal: { kind: "server" }, telemetry, readyTimeout, @@ -547,6 +548,7 @@ export async function rawLinked( ): Promise<{ readonly server: Host; readonly peer: RawPeer; readonly errors: HostError[] }> { const errors: HostError[] = []; const server = createHost({ + address: Server(), principal: { kind: "server" }, readyTimeout: options.readyTimeout, compression: options.compression, @@ -580,8 +582,19 @@ const NOTES_VARIANTS = { reader: { state: { notes: arr(obj({ text: str() })), count: int() }, methods: { wait: method() } }, }; +/** The host kind of the test servers, where every test type but `PlacedNotes` lives. */ +export const Server = hostKind("server"); + +/** `Server()` as a hold names it on the wire: the address of every test type served on the server. */ +export const SERVER_ADDRESS = { kind: "server", params: {} }; + /** The remote type of the link tests: notes per session, an editor variant for owners, a reader one for guests. */ -export const Notes = remote("app.notes", { version: 1, params: { session: str() }, variants: NOTES_VARIANTS }); +export const Notes = remote("app.notes", { + version: 1, + params: { session: str() }, + hostAddress: () => Server(), + variants: NOTES_VARIANTS, +}); /** The kind of the relay tests' workers: one per session. */ export const Session = hostKind("session", { id: str() }); @@ -590,7 +603,7 @@ export const Session = hostKind("session", { id: str() }); export const PlacedNotes = remote("app.placed-notes", { version: 1, params: { session: str() }, - placement: ({ session }) => Session({ id: session }), + hostAddress: ({ session }) => Session({ id: session }), variants: NOTES_VARIANTS, }); @@ -630,7 +643,7 @@ export function serveNotes( }, }; }); - // The two types differ in their name and placement only. + // The two types differ in their name and host address only. host.serve(Type as typeof Notes, ({ session }, principal, self) => { probe.factories.push(`${session} ${principal.kind}`); const store = self.get(NoteStore, { session }); @@ -807,6 +820,7 @@ export async function relayed( const resolves: string[] = []; const { revokedBy, compression = {} } = options; const server = createHost({ + address: Server(), principal: { kind: "server" }, revokedBy: options.serverRevokedBy ?? revokedBy, compression: compression.server,