clone

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

feat(runtime): surface the store state as a boot diagnostic (#135)

FelineStateMachine committed c656ef5
Boot now consumes the ticket-scoped store-status preflight: when a runtime
connects managed sync through a ticket-gated sink, createClient resolves
readTicketStoreStatus alongside database creation and records the answer as
diagnostics.storeStatus, so a schema-less or drifted store surfaces as a named
boot state instead of a hanging first write. The preflight never gates boot —
local readiness does not wait on it, a hung or failing request degrades to
store_unavailable within a few seconds, and nothing is ever auto-repaired;
no_schema carries the provisioning remediation message. Non-ticket sinks and
local-only devices keep a documented unchecked value, never a false alarm.
The state shows in the development inspector's Store row and DeviceStatus.

Part of #109.
commit
c656ef5a0c66307481919efd12b43da6bde77a8d
parent
c4ef777579023db6ec6ee769a57ca23db1ba2379
author
FelineStateMachine <dami.etoile@gmail.com>
13 files changed, +312 -1
Mdocs/store-provisioning.md5+
Mdocs/troubleshooting.md10++
Mpackage/astro/mod.ts1+
Mpackage/preact/DeviceStatus.tsx5+
Mpackage/runtime/data-sink.ts14+++
Mpackage/runtime/diagnostics.ts3+
Mpackage/runtime/inspector.ts2+
Mpackage/runtime/inspector_test.ts5+
Mpackage/runtime/mod.ts1+
Mpackage/runtime/probe.ts2+
Mpackage/runtime/runtime.ts13+++-
Apackage/runtime/store-status.ts122++++++++++++++++++++++++++++
Apackage/runtime/store-status_test.ts130++++++++++++++++++++++++++++++
M docs/store-provisioning.md +5 -0
M docs/troubleshooting.md +10 -0
M package/astro/mod.ts +1 -0
M package/preact/DeviceStatus.tsx +5 -0
M package/runtime/data-sink.ts +14 -0
M package/runtime/diagnostics.ts +3 -0
M package/runtime/inspector.ts +2 -0
M package/runtime/inspector_test.ts +5 -0
M package/runtime/mod.ts +1 -0
M package/runtime/probe.ts +2 -0
M package/runtime/runtime.ts +12 -1
@@ -3,7 +3,8 @@ import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";
33 // Package-owned Jazz runtime.44 import { getLofiApp } from "./app.ts";55 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";6-import { appId, databaseConfig, syncing } from "./config.ts";6+import { activeSink, appId, databaseConfig, syncing } from "./config.ts";7+import { resolveStoreStatus } from "./store-status.ts";78 import { setEncryptedColumnKey } from "../schema/encrypted.ts";89 import { assertDurableBrowser } from "./device-capabilities.ts";910 import {
@@ -201,7 +202,17 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
201202 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);202203 state.diagnostics.storageState = "persistent-requested";203204 state.diagnostics.startupFailure = null;205+ state.diagnostics.storeStatus = { state: "unchecked", reason: "sync-not-connected" };204206 notifyDiagnostics(state);207+ // The store preflight rides alongside database creation, never in front of208+ // it: local-first boot must not wait on the network. resolveStoreStatus maps209+ // every failure and timeout to a diagnostic value, so this only records210+ // state — a schema-less or drifted store surfaces here at boot instead of as211+ // a hanging first write, and is never repaired from here.212+ void resolveStoreStatus({ connect, sink: activeSink() }).then((status) => {213+ state.diagnostics.storeStatus = status;214+ notifyDiagnostics(state);215+ });205216 return await runRuntimeStartup(runtimeMode, async () => {206217 assertDurableBrowser();207218 const secret = await resolveAccountSecret();
A package/runtime/store-status.ts +122 -0
@@ -0,0 +1,122 @@
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+ */16+import { readTicketStoreStatus, type TicketStoreStatus } from "../schema/store.ts";17+import type { ActiveSink } from "./config.ts";18+import { isTicketServerUrl } from "./data-sink.ts";19+20+/**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+ */32+export 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" };39+40+// Matches the actionable voice of the startup-recovery messages: name the41+// consequence, then the one remediation.42+const 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.";45+46+/** How one boot's store preflight is supplied; tests inject the dependencies. */47+export 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+};57+58+// 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.61+async 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+}78+79+/**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+ */85+export 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+}103+104+/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */105+export 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+}
A package/runtime/store-status_test.ts +130 -0
@@ -0,0 +1,130 @@
1+// Package contract tests for the boot store-status diagnostic: when the2+// preflight runs at all, how every node answer maps onto the runtime3+// diagnostic, and that failures and timeouts degrade to a diagnostic value4+// instead of gating or failing boot.5+import type { TicketStoreStatus } from "../schema/store.ts";6+import { isTicketServerUrl } from "./data-sink.ts";7+import {8+ describeStoreStatus,9+ resolveStoreStatus,10+ type RuntimeStoreStatus,11+} from "./store-status.ts";12+import { assert } from "./test-assert.ts";13+14+const ticketUrl = `http://192.168.1.10:4802/t/${"s".repeat(43)}`;15+16+Deno.test("the diagnostic stays unchecked without managed sync or a ticket-gated sink", async () => {17+ let calls = 0;18+ const preflight = (): Promise<TicketStoreStatus> => {19+ calls += 1;20+ return Promise.resolve({ state: "no_schema", appId: "a-app" });21+ };22+ const notConnected = await resolveStoreStatus({23+ connect: false,24+ sink: { serverUrl: ticketUrl },25+ preflight,26+ });27+ assert(28+ notConnected.state === "unchecked" && notConnected.reason === "sync-not-connected",29+ `a non-connecting runtime resolved to ${JSON.stringify(notConnected)}`,30+ );31+ const localOnly = await resolveStoreStatus({ connect: true, sink: null, preflight });32+ assert(33+ localOnly.state === "unchecked" && localOnly.reason === "sync-not-connected",34+ `a local-only device resolved to ${JSON.stringify(localOnly)}`,35+ );36+ const firstParty = await resolveStoreStatus({37+ connect: true,38+ sink: { serverUrl: "https://sync.example.com" },39+ preflight,40+ });41+ assert(42+ firstParty.state === "unchecked" && firstParty.reason === "sink-not-ticket-gated",43+ `a first-party server sink resolved to ${JSON.stringify(firstParty)}`,44+ );45+ assert(calls === 0, "an unchecked diagnostic must never touch the network");46+});47+48+Deno.test("every preflight answer maps onto the runtime diagnostic", async () => {49+ const resolve = (answer: TicketStoreStatus): Promise<RuntimeStoreStatus> =>50+ resolveStoreStatus({51+ connect: true,52+ sink: { serverUrl: ticketUrl },53+ preflight: (url) => {54+ assert(url === ticketUrl, `the preflight received ${url}`);55+ return Promise.resolve(answer);56+ },57+ });58+ const deployed = await resolve({ state: "deployed", appId: "a-app", headHash: "ff85" });59+ assert(60+ deployed.state === "deployed" && deployed.headHash === "ff85",61+ `a deployed store resolved to ${JSON.stringify(deployed)} — the head hash must surface ` +62+ "verbatim so drift against this app's deployment stays a human judgement",63+ );64+ const fresh = await resolve({ state: "no_schema", appId: "a-app" });65+ assert(fresh.state === "no_schema", `a schema-less store resolved to ${JSON.stringify(fresh)}`);66+ assert(67+ fresh.state === "no_schema" && fresh.message.includes("hang") &&68+ fresh.message.includes("Provision"),69+ "no_schema must carry the actionable message naming the hanging-write consequence and the " +70+ "provisioning remediation",71+ );72+ for (const state of ["store_unavailable", "ticket_rejected", "unsupported"] as const) {73+ const mapped = await resolve({ state });74+ assert(mapped.state === state, `${state} resolved to ${JSON.stringify(mapped)}`);75+ }76+});77+78+Deno.test("a hung or failing preflight degrades to store_unavailable", async () => {79+ const hung = await resolveStoreStatus({80+ connect: true,81+ sink: { serverUrl: ticketUrl },82+ preflight: () => new Promise<TicketStoreStatus>(() => {}),83+ timeoutMs: 20,84+ });85+ assert(86+ hung.state === "store_unavailable",87+ `a black-holed preflight resolved to ${JSON.stringify(hung)}`,88+ );89+ const failed = await resolveStoreStatus({90+ connect: true,91+ sink: { serverUrl: ticketUrl },92+ preflight: () => Promise.reject(new Error("network down")),93+ });94+ assert(95+ failed.state === "store_unavailable",96+ `a rejecting preflight resolved to ${JSON.stringify(failed)} instead of a diagnostic value`,97+ );98+});99+100+Deno.test("only a /t/<secret> path counts as a ticket-gated server URL", () => {101+ assert(isTicketServerUrl(ticketUrl), "a valid ticket URL must be recognized");102+ assert(103+ !isTicketServerUrl("https://sync.example.com"),104+ "a first-party server URL must not be probed",105+ );106+ assert(107+ !isTicketServerUrl("https://node.example/t/short"),108+ "a short secret segment must not be treated as a ticket path",109+ );110+ assert(!isTicketServerUrl("not a url"), "a malformed URL must not be treated as a ticket path");111+});112+113+Deno.test("describeStoreStatus gives every state a distinct label", () => {114+ const statuses: RuntimeStoreStatus[] = [115+ { state: "unchecked", reason: "sync-not-connected" },116+ { state: "unchecked", reason: "sink-not-ticket-gated" },117+ { state: "deployed", headHash: "ff85" },118+ { state: "no_schema", message: "provision" },119+ { state: "store_unavailable" },120+ { state: "ticket_rejected" },121+ { state: "unsupported" },122+ ];123+ const labels = statuses.map(describeStoreStatus);124+ assert(new Set(labels).size === labels.length, `labels collide: ${labels.join(" | ")}`);125+ assert(labels[2].includes("ff85"), "the deployed label must show the head hash");126+ assert(127+ labels[0].startsWith("not checked") && labels[1].startsWith("not checked"),128+ "unchecked labels must read as not-a-problem, never as an alarm",129+ );130+});