/** Host publication and display of a custody room's answer, once per instance. */ import { type CellHandle, type JSONValue, type RuntimeClient, } from "@commonfabric/runtime-client"; import { consume } from "@lit/context"; import { html, nothing, type PropertyValues } from "lit"; import { property } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { runtimeContext } from "../../runtime-context.ts"; /** * The seal's refusals while an answer is not, or is already, published: the * slot says which. Anything else, such as a lost worker connection or a slot * the seal did not write, is said to the room's readers. */ const EXPECTED_REFUSALS = [ "Custody answer requires every seat to have sealed", "Custody answer requires a value the room's policy releases to the seal", "Custody answer is already published for this instance", "Custody answer's room changed while publishing", ]; const isExpectedRefusal = (error: unknown): boolean => error instanceof Error && EXPECTED_REFUSALS.some((refusal) => error.message.includes(refusal)); /** * What the seal publishes: a string of at most 1,024 characters, a number, or * a boolean. */ type CustodyAnswer = string | number | boolean; const isCustodyAnswer = (value: JSONValue): value is CustodyAnswer => typeof value === "string" || typeof value === "number" || typeof value === "boolean"; const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); /** * Publishes a custody room's answer once and shows it. A room binds it as * ``, * where `output` is the room's projected answer. Each time the projected answer * changes, the component asks the worker to publish it; the seal publishes it * once, only when every rule of the room's policy requires the seal's witness * and releases only to the seal, a rule releases it to the seal, and every seat * has sealed, and refuses every later request. Before the room has terms it * asks nothing. A failure other than those refusals is shown as an alert, and * the next change asks again. The component shows, as text, what the seal * published, which is a string, a number or a boolean; any other value is shown * as an alert, not as the answer. It is read by the worker from the slot the * seal derives from the room's terms and policy and verified to be the seal's * own write, never a value the room holds. That is the slot of the instance the * bound `terms` digest to, under the policy the bound `policy` cell names. The * slot is create-only and the seal's alone to write, so the answer published * for an instance never changes. Which instance the component shows is not held * against a room member's own code: its bindings are pattern data the room * space's members can write, so a member's code can point them at another * instance's terms, or at terms whose slot is empty. A writer claim on the * room's cells does not close that in general, as write authority is keyed by * code (normative CFC ยง8.15.8). * * @element cf-custody-answer * @fires cf-published - The answer is published or found published; * `detail.instance` names the instance when this component published it. */ export class CFCustodyAnswer extends BaseElement { /** Runtime supplied by the host's context provider. */ @consume({ context: runtimeContext, subscribe: true }) @property({ attribute: false }) accessor runtime: RuntimeClient | undefined; /** The room's terms document; its space is the room space. */ @property({ attribute: false }) accessor terms: CellHandle | undefined; /** A cell holding the room's custody policy reference. */ @property({ attribute: false }) accessor policy: CellHandle | undefined; /** The room's projected answer. */ @property({ attribute: false }) accessor output: CellHandle | undefined; #unsubscribe: (() => void) | undefined; #published = false; #answer: CustodyAnswer | undefined; #inFlight: Promise | undefined; #again = false; #generation = 0; #error = ""; /** Exercises the publication without a subscription. */ get accessForTestingOnly(): { publish(): Promise; readonly published: boolean; readonly answer: CustodyAnswer | undefined; readonly error: string; } { // deno-lint-ignore no-this-alias const component = this; return { publish: () => this.#publish(), get published() { return component.#published; }, get answer() { return component.#answer; }, get error() { return component.#error; }, }; } override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); if ( ["terms", "policy", "output", "runtime"].some((key) => changed.has(key)) ) { this.#generation++; this.#published = false; this.#answer = undefined; this.#error = ""; this.#subscribe(); } } override connectedCallback(): void { super.connectedCallback(); this.#subscribe(); } override disconnectedCallback(): void { // A request still out when the element leaves belongs to it no more: // what it returns is dropped, and a request queued behind it never runs. this.#generation++; this.#again = false; this.#unsubscribe?.(); this.#unsubscribe = undefined; super.disconnectedCallback(); } override render() { return html`${ this.#answer === undefined ? nothing : html`${String(this.#answer)}` }${ this.#error ? html`

${this.#error}

` : nothing }`; } #subscribe(): void { this.#unsubscribe?.(); this.#unsubscribe = undefined; const { output, terms, policy } = this; if (!output || !terms || !policy || !this.isConnected) return; // New terms are a new instance, and a new policy names another instance's // slot: either has an answer of its own to publish and show. Subscribing // delivers each cell's current value at once, which is where this binding // starts, so it starts over once for all of them. const startOver = () => { this.#generation++; this.#published = false; this.#answer = undefined; this.#error = ""; this.requestUpdate(); }; startOver(); let subscribing = true; const onBinding = () => { if (!subscribing) startOver(); void this.#publish(); }; // Terms or a policy the worker will not show name no instance this // element may publish for: what it showed goes, and nothing is asked. const onRefusedBinding = () => { if (!subscribing) startOver(); }; const cancelTerms = terms.subscribe(onBinding, { onRefused: onRefusedBinding, }); const cancelPolicy = policy.subscribe(onBinding, { onRefused: onRefusedBinding, }); const cancelOutput = output.subscribe(() => { void this.#publish(); }, { // The output is not read here: it is the worker's to release, so its // refusal changes nothing this element asks. onRefused: () => {}, }); subscribing = false; this.#unsubscribe = () => { cancelTerms(); cancelPolicy(); cancelOutput(); }; } /** Asks the worker to publish; a request made while one runs runs after. */ async #publish(): Promise { if (this.#inFlight) { this.#again = true; return await this.#inFlight; } this.#inFlight = this.#request(); try { await this.#inFlight; } finally { this.#inFlight = undefined; } // A request queued behind this one runs only if this one did not // publish; either way the queue is empty now. const queued = this.#again; this.#again = false; if (queued && !this.#published) await this.#publish(); } async #request(): Promise { const { runtime, terms, policy, output } = this; if (this.#published || !runtime || !terms || !policy || !output) return; // A room that has not proposed yet has no terms, and so no instance to // publish for: nothing to ask, and nothing to say. The terms // subscription asks once they are written. // Terms the worker refuses are not terms to publish for: nothing is // asked while the refusal stands. Nor is anything asked for a policy the // worker refuses or has not answered: the terms' subscription can ask // before the policy's has heard from the worker. if (!("value" in policy.lastRead())) return; const proposed = terms.refusal === undefined ? terms.get() : undefined; if (!proposed || typeof proposed !== "object" || Array.isArray(proposed)) { if (this.#error) { this.#error = ""; this.requestUpdate(); } return; } const generation = this.#generation; let instance: string | undefined; let error = ""; try { instance = (await runtime.publishCustodyAnswer({ terms: terms.ref(), policy: policy.ref(), output: output.ref(), })).instance; } catch (refused) { // Not yet released, not every seat has sealed, or already published: // the slot says which. Anything else is said. if (!isExpectedRefusal(refused)) error = messageOf(refused); } // Rebound or detached meanwhile: this request's instance is not the one // shown any more. if (generation !== this.#generation) return; // Shown from the slot itself, whoever published it. let answer: CustodyAnswer | undefined; try { const read = await runtime.readCustodyAnswer({ terms: terms.ref(), policy: policy.ref(), }); if (read !== undefined && !isCustodyAnswer(read)) { throw new Error( "Custody answer refuses an answer that is not a scalar", ); } answer = read; // Published, by this request or another member's: whatever this // request met on the way no longer matters. if (answer !== undefined) error = ""; } catch (failed) { error = messageOf(failed); } if (generation !== this.#generation) return; if (error !== this.#error) { this.#error = error; this.requestUpdate(); } if (answer === undefined) return; this.#published = true; this.#answer = answer; this.requestUpdate(); this.emit("cf-published", instance === undefined ? {} : { instance }); } }