diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 6026662a6..67a734a21 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -13,6 +13,7 @@ - A derivation is new at every reload (computed at the commit) instead of kept with its function replaced. A listener whose state source a reload replaced subscribes again and gets the current value again: listeners on state must reconcile. Listeners on state subscribe when they activate, so they get the value current then - `method(args?, result?)`: without arguments a method is called `ref.name(ctx?)` and implemented `(ctx) => …`; without a result it is void (callers get `undefined`). Descriptions record both by leaving `args`/`result` out (fingerprints of such methods change), `call` frames omit `args` and `result` frames `value` for them. Coverage requires both sides to agree on arguments; a void consumer accepts a result - `FacetApi` has a `signal` (below): code that builds a `FacetApi` by hand, for example to wrap `serve`, must pass it on +- `serve(type, factory, { lifetime: "host" })` compiles only for a local type whose input has no required fields, and without `linger`; `serve` still refuses the rest at runtime. `ServeOptions` is a union of `ReferencedServeOptions` and `HostServeOptions`, and `serve` takes `ServeOptionsFor` ### Added diff --git a/packages/chord/docs/guide.md b/packages/chord/docs/guide.md index 9d6236971..5ab92ba30 100644 --- a/packages/chord/docs/guide.md +++ b/packages/chord/docs/guide.md @@ -487,7 +487,8 @@ host.serve(Index, indexFactory, { lifetime: "host" }); // one objec **`lifetime: "host"`** is for local types with an empty input (or only defaults): the type has exactly one object, created for the host principal when its facet loads. Every `get` reaches that object, whoever asks; a `get` with another input is invalid. References others hold do not change its lifetime, and it cannot `linger`. Only such -objects may register into registries. +objects may register into registries. TypeScript refuses `lifetime: "host"` for a remote type, for an input with a +required field and together with `linger`; `serve` checks the same at runtime, for callers without types. ## Registries diff --git a/packages/chord/docs/spec.md b/packages/chord/docs/spec.md index 29d3f4ea9..dd72a189c 100644 --- a/packages/chord/docs/spec.md +++ b/packages/chord/docs/spec.md @@ -137,7 +137,8 @@ Inside the factory, placeholders must not be used (`resource.value` throws `not_ | `"referenced"` (default) | at the first `get` of its address | after the last reference is released, plus `linger` | allowed (default 0) | | `"host"` | when its facet loads (or at `serve`); loading waits until it is live | when the host closes, or a reload removes its facet | refused | -- `"host"` is refused at `serve` for remote types, for an input that does not accept `{}`, and together with `linger`. +- `"host"` is refused at `serve` for remote types, for an input that does not accept `{}`, and together with `linger`; + the types (`ServeOptionsFor`) refuse the same at compile time, where such an input is one with a required field. The type has exactly one object: the empty input (defaults applied), created for the host principal. Every `get` reaches it, whatever its principal (the factory sees the host principal); a `get` with another input is `invalid/input`. Other references do not change its lifetime: the host keeps its own until it closes. diff --git a/packages/chord/src/host.ts b/packages/chord/src/host.ts index 475f1d9d5..35423e8d7 100644 --- a/packages/chord/src/host.ts +++ b/packages/chord/src/host.ts @@ -24,6 +24,7 @@ import { RefImpl, type RefOwner, type ServeOptions, + type ServeOptionsFor, type Service, } from "./object.ts"; import { enqueue } from "./publish.ts"; @@ -42,8 +43,16 @@ export const hostPrincipal: Principal = Object.freeze({ host: true }); * keeps literal `variant`s in factory returns. */ export interface Serve { - (type: RemoteContract, factory: Factory>, options?: ServeOptions): void; - (type: LocalContract, factory: Factory>, options?: ServeOptions): void; + ( + type: RemoteContract, + factory: Factory>, + options?: ServeOptionsFor>, + ): void; + ( + type: LocalContract, + factory: Factory>, + options?: ServeOptionsFor>, + ): void; } type AnyServe = (type: AnyContract, factory: Factory, options?: ServeOptions) => void; diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 9e3cd3702..532b818b2 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -49,7 +49,16 @@ export { type WebSocketLinkOptions, webSocketLink, } from "./link.ts"; -export type { Factory, Lifetime, Self, ServeOptions, StateOptions } from "./object.ts"; +export type { + Factory, + HostServeOptions, + Lifetime, + ReferencedServeOptions, + Self, + ServeOptions, + ServeOptionsFor, + StateOptions, +} from "./object.ts"; export { defineRegistry, type Registry, type RegistryTable, type RegistryView } from "./registry.ts"; export type { ReloadOptions, ReloadReport } from "./reload.ts"; export { diff --git a/packages/chord/src/object.ts b/packages/chord/src/object.ts index e0d281def..3c808f841 100644 --- a/packages/chord/src/object.ts +++ b/packages/chord/src/object.ts @@ -9,6 +9,7 @@ import { type ImplementationOf, type InputOf, LOCAL_VARIANT, + type LocalContract, type MemberDescription, type Ref, takesNoArgs, @@ -24,21 +25,42 @@ const noop = (): void => {}; // ─── Services ──────────────────────────────────────────────────────────────── -export interface ServeOptions { - /** - * How long objects of the type live. `"referenced"` (default): created at the first `get` of an address, disposed - * after the last reference is released (plus `linger`). `"host"`: one object, created when its facet loads (loading - * waits until it is live) and kept until the host closes or a reload removes its facet. Only local types with an - * empty input may have it; every `get` reaches that one object, created for the host principal. Only such objects - * may register into registries. - */ - readonly lifetime?: Lifetime; - /** Milliseconds an object stays alive after its last reference is released (`lifetime: "referenced"` only). */ +/** + * `serve` options. `lifetime` says how long objects of the type live. `"referenced"` (default): created at the first + * `get` of an address, disposed after the last reference is released (plus `linger`). `"host"`: one object, created when + * its facet loads (loading waits until it is live) and kept until the host closes or a reload removes its facet. Only + * local types whose input accepts `{}` may have it (`ServeOptionsFor` checks that at compile time, `serve` again at + * runtime); every `get` reaches that one object, created for the host principal. Only such objects may register into + * registries. + */ +export type ServeOptions = ReferencedServeOptions | HostServeOptions; + +export interface ReferencedServeOptions { + readonly lifetime?: "referenced"; + /** Milliseconds an object stays alive after its last reference is released. */ readonly linger?: number; /** Adapters for consumers built against older contract versions. */ readonly accepts?: Accepts; } +export interface HostServeOptions { + readonly lifetime: "host"; + /** An object of lifetime `"host"` lives as long as the host: it does not linger. */ + readonly linger?: undefined; + /** Adapters for consumers built against older contract versions. */ + readonly accepts?: Accepts; +} + +/** + * The options `serve` accepts for `C`: `lifetime: "host"` only for a local type whose input has no required fields + * (the type's one object has the empty input). + */ +export type ServeOptionsFor = C extends LocalContract + ? Record extends InputOf + ? ServeOptions + : ReferencedServeOptions + : ReferencedServeOptions; + export type Lifetime = "referenced" | "host"; export type Factory = ( diff --git a/packages/chord/test/host.test.ts b/packages/chord/test/host.test.ts index d682f5911..9abc16b53 100644 --- a/packages/chord/test/host.test.ts +++ b/packages/chord/test/host.test.ts @@ -388,23 +388,27 @@ describe("host: lifetime", () => { members: { seen: opaque("t.seen@1") }, }); - it('serve refuses a lifetime "host" the type cannot have', () => { + it('serve refuses a lifetime "host" the type cannot have, at compile time and at runtime', () => { const host = quiet(); const Remote = remote("t.remote-store", { version: 1, input: Type.Object({}), variants: { main: {} } }); - assert.throws( - () => host.serve(Remote, () => ({ variant: "main" }), { lifetime: "host" } as never), - /needs a local type/, - ); const Keyed = local("t.keyed", { version: 1, input: Type.Object({ id: Type.String() }) }); - assert.throws(() => host.serve(Keyed, () => ({}), { lifetime: "host" }), /input that accepts \{\}/); - assert.throws( - () => host.serve(Store, () => ({ seen: null }), { lifetime: "host", linger: 10 }), - /does not linger/, - ); - assert.throws( - () => host.serve(Store, () => ({ seen: null }), { lifetime: "forever" } as never), - /unknown lifetime/, - ); + // Each refused call is also a type error; the runtime check is the backstop for untyped callers. + const refused: [() => void, RegExp][] = [ + // @ts-expect-error: a remote type is never of lifetime "host" + [() => host.serve(Remote, () => ({ variant: "main" }), { lifetime: "host" }), /needs a local type/], + // @ts-expect-error: the one object has the empty input, and `id` is required + [() => host.serve(Keyed, () => ({}), { lifetime: "host" }), /input that accepts \{\}/], + // @ts-expect-error: an object of lifetime "host" does not linger + [() => host.serve(Store, () => ({ seen: null }), { lifetime: "host", linger: 10 }), /does not linger/], + // @ts-expect-error: no such lifetime + [() => host.serve(Store, () => ({ seen: null }), { lifetime: "forever" }), /unknown lifetime/], + ]; + for (const [serve, error] of refused) assert.throws(serve, error); + // Allowed: a local type whose input has only optional fields, and any type with the default lifetime. + const Optional = local("t.optional", { version: 1, input: Type.Object({ id: Type.Optional(Type.String()) }) }); + host.serve(Optional, () => ({}), { lifetime: "host" }); + host.serve(Keyed, () => ({}), { lifetime: "referenced", linger: 10 }); + host.serve(Remote, () => ({ variant: "main" }), { linger: 10 }); }); it("every get reaches the one object, created at serve for the host principal; another input is invalid", async () => {