clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

main
lofi / package / runtime / store-status.ts
store-status.ts 5.2K
1/**2 * Package-owned boot preflight of the connected store: the runtime-diagnostics3 * side of {@link readTicketStoreStatus}.4 *5 * Against a store with no deployed schema, the engine's writes hang rather6 * than fail. When boot connects managed sync to a ticket-gated node, the7 * runtime asks that node's metadata-only store-status endpoint and records the8 * answer in runtime diagnostics, so the hanging-write symptom surfaces as a9 * named boot state instead. The preflight never gates boot — local readiness10 * does not wait on it, and every failure or timeout maps to a diagnostic11 * value. Nothing here repairs a store: `no_schema` and a differing head are12 * reported for the user to act on through the provisioning opt-in.13 *14 * @module15 */16import { readTicketStoreStatus, type TicketStoreStatus } from "../schema/store.ts";17import type { ActiveSink } from "./config.ts";18import { isTicketServerUrl } from "./data-sink.ts";1920/**21 * The boot store preflight carried in runtime diagnostics.22 *23 * `unchecked` is the documented no-alarm value: the runtime is not connecting24 * managed sync, or the active sink is not a ticket-gated node URL (first-party25 * Jazz servers and open-mode nodes expose no store-status endpoint). The other26 * states relay the node's metadata-only answer verbatim. `no_schema` carries27 * the actionable message — writes against such a store hang until it is28 * provisioned. `deployed` carries the store's newest schema hash so a mismatch29 * with this app's deployment can be judged by a human; nothing is ever30 * auto-repaired from this diagnostic.31 */32export type RuntimeStoreStatus =33 | { state: "unchecked"; reason: "sync-not-connected" | "sink-not-ticket-gated" }34 | { state: "deployed"; headHash: string }35 | { state: "no_schema"; message: string }36 | { state: "store_unavailable" }37 | { state: "ticket_rejected" }38 | { state: "unsupported" };3940// Matches the actionable voice of the startup-recovery messages: name the41// consequence, then the one remediation.42const noSchemaMessage =43 "The connected store has no schema deployed for this app, so writes will hang instead of " +44 "syncing. Provision the store (see docs/store-provisioning.md), then reload.";4546/** How one boot's store preflight is supplied; tests inject the dependencies. */47export type StoreStatusPreflightOptions = {48 /** Whether this runtime is actually connecting managed sync. */49 connect: boolean;50 /** The sync location in effect, or `null` for a local-only device. */51 sink: Pick<ActiveSink, "serverUrl"> | null;52 /** The metadata-only preflight; defaults to {@link readTicketStoreStatus}. */53 preflight?: (ticketUrl: string) => Promise<TicketStoreStatus>;54 /** How long the preflight may take before the store is reported unreachable. */55 timeoutMs?: number;56};5758// The classifier itself maps a failed fetch to store_unavailable; the race59// extends the same meaning to a hung one, so boot diagnostics settle within a60// few seconds even when the node black-holes the request.61async function raceTimeout(62 answer: Promise<TicketStoreStatus>,63 timeoutMs: number,64): Promise<TicketStoreStatus> {65 let timer: ReturnType<typeof setTimeout> | undefined;66 const expired = new Promise<TicketStoreStatus>((resolve) => {67 timer = setTimeout(() => resolve({ state: "store_unavailable" }), timeoutMs);68 });69 try {70 return await Promise.race([71 answer.catch((): TicketStoreStatus => ({ state: "store_unavailable" })),72 expired,73 ]);74 } finally {75 clearTimeout(timer);76 }77}7879/**80 * Resolves the store-status diagnostic for one boot. Never throws and never81 * repairs: a preflight error or timeout resolves to `store_unavailable`, and a82 * non-connecting runtime or non-ticket sink resolves to `unchecked` without83 * touching the network — the caller records the value, nothing more.84 */85export async function resolveStoreStatus(86 options: StoreStatusPreflightOptions,87): Promise<RuntimeStoreStatus> {88 const { connect, sink, preflight = readTicketStoreStatus, timeoutMs = 4000 } = options;89 if (!connect || !sink) return { state: "unchecked", reason: "sync-not-connected" };90 if (!isTicketServerUrl(sink.serverUrl)) {91 return { state: "unchecked", reason: "sink-not-ticket-gated" };92 }93 const answer = await raceTimeout(preflight(sink.serverUrl), timeoutMs);94 switch (answer.state) {95 case "deployed":96 return { state: "deployed", headHash: answer.headHash };97 case "no_schema":98 return { state: "no_schema", message: noSchemaMessage };99 default:100 return { state: answer.state };101 }102}103104/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */105export function describeStoreStatus(status: RuntimeStoreStatus): string {106 switch (status.state) {107 case "unchecked":108 return status.reason === "sink-not-ticket-gated"109 ? "not checked (sink has no status endpoint)"110 : "not checked (sync not connected)";111 case "deployed":112 return `deployed (head ${status.headHash})`;113 case "no_schema":114 return "no schema deployed — provision the store";115 case "store_unavailable":116 return "store unreachable";117 case "ticket_rejected":118 return "ticket rejected";119 case "unsupported":120 return "status endpoint unsupported";121 }122}