feat(chord): lifetime "host" is checked by the type system

serve(Type, factory, { lifetime: "host" }) compiles only for a local type whose input has no required fields, and
not together with linger; the runtime check stays as a backstop. ServeOptions is a union (ReferencedServeOptions,
HostServeOptions), picked per contract by ServeOptionsFor<C>.
This commit is contained in:
Mario Zechner 2026-10-14 09:43:42 +00:00
parent b042997178
commit 301617df7b
7 changed files with 76 additions and 29 deletions

View file

@ -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<C>`
### Added

View file

@ -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

View file

@ -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.

View file

@ -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 {
<D extends RemoteDef>(type: RemoteContract<D>, factory: Factory<RemoteContract<D>>, options?: ServeOptions): void;
<D extends LocalDef>(type: LocalContract<D>, factory: Factory<LocalContract<D>>, options?: ServeOptions): void;
<D extends RemoteDef>(
type: RemoteContract<D>,
factory: Factory<RemoteContract<D>>,
options?: ServeOptionsFor<RemoteContract<D>>,
): void;
<D extends LocalDef>(
type: LocalContract<D>,
factory: Factory<LocalContract<D>>,
options?: ServeOptionsFor<LocalContract<D>>,
): void;
}
type AnyServe = (type: AnyContract, factory: Factory<AnyContract>, options?: ServeOptions) => void;

View file

@ -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 {

View file

@ -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 AnyContract> = C extends LocalContract
? Record<never, never> extends InputOf<C>
? ServeOptions
: ReferencedServeOptions
: ReferencedServeOptions;
export type Lifetime = "referenced" | "host";
export type Factory<C extends AnyContract> = (

View file

@ -388,23 +388,27 @@ describe("host: lifetime", () => {
members: { seen: opaque<unknown>("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 () => {