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>
| M | docs/ | 5 | + |
| M | docs/ | 10 | ++ |
| M | package/ | 1 | + |
| M | package/ | 5 | + |
| M | package/ | 14 | +++ |
| M | package/ | 3 | + |
| M | package/ | 2 | + |
| M | package/ | 5 | + |
| M | package/ | 1 | + |
| M | package/ | 2 | + |
| M | package/ | 13 | +++- |
| A | package/ | 122 | ++++++++++++++++++++++++++++ |
| A | package/ | 130 | ++++++++++++++++++++++++++++++ |
@@ -57,6 +57,11 @@ const preflight = await readTicketStoreStatus(ticketUrl);
5757 writes hang rather than fail, so an app should reach this state — and prompt the user — before ever5858 attaching sync to a fresh store.5959 60+The browser runtime runs this preflight itself on every boot that connects managed sync to a61+ticket-gated sink, recording the answer as `storeStatus` in runtime diagnostics. It never blocks62+local readiness and never provisions anything; it only names the state, so a schema-less store shows63+up as a boot diagnostic instead of a hanging first write.64+6065 ## Provision6166 6267 ```ts@@ -127,6 +127,16 @@ Clearing site data partially — IndexedDB without localStorage, or a browser "f
127127 rather than guessing. Local data is untouched. Re-enroll the ticket (paste it again) to declare the128128 sink and resume syncing under the same account.129129 130+## Sync is connected but writes to a self-hosted store never complete131+132+Against a store with no deployed schema, writes hang rather than fail. When the active sink is a133+ticket-gated node URL, the runtime checks the store at boot and records the result as `storeStatus`134+in runtime diagnostics; the development inspector's **Store** row and the `DeviceStatus` panel135+display it. `no_schema` means the store has never been provisioned for this app — follow136+[Store provisioning](store-provisioning.md), then reload. The runtime only reports store state; it137+never provisions or repairs a store on its own, and for non-ticket sinks the row stays138+`not checked`.139+130140 ## Recovery does not restore a recent item131141 132142 The phrase restores account authority, not unsynced device storage. Data returns only if it reached@@ -48,6 +48,7 @@ export const runtimeFiles = [
4848 "runtime.ts",4949 "session.ts",5050 "startup-recovery.ts",51+ "store-status.ts",5152 "table-mutations.ts",5253 "table-store.ts",5354 "transport-gate.ts",@@ -11,6 +11,7 @@ import {
1111 subscribeRuntimeDiagnostics,1212 } from "../runtime/runtime.ts";1313 import { readSession, type Session } from "../runtime/session.ts";14+import { describeStoreStatus } from "../runtime/store-status.ts";1415 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";1516 1617 /**@@ -131,7 +132,11 @@ export default function DeviceStatus(): VNode {
131132 <dl>132133 <Row label="Sync" value={syncState} />133134 <Row label="Sync location" value={sinkState} />135+ <Row label="Store" value={describeStoreStatus(runtimeDiagnostics.storeStatus)} />134136 </dl>137+ {runtimeDiagnostics.storeStatus.state === "no_schema" && (138+ <p>{runtimeDiagnostics.storeStatus.message}</p>139+ )}135140 {!synced && (136141 <p>137142 Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}@@ -301,6 +301,20 @@ export type SyncTicket = {
301301 const TICKET_PREFIX = "lofisync1.";302302 const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;303303 304+/**305+ * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) and306+ * therefore fronts a lofi-node gate, which is what exposes the metadata-only307+ * store-status endpoint. First-party Jazz servers and open-mode node URLs do308+ * not match.309+ */310+export function isTicketServerUrl(serverUrl: string): boolean {311+ try {312+ return TICKET_PATH.test(new URL(serverUrl).pathname);313+ } catch {314+ return false;315+ }316+}317+304318 /**305319 * Parses a pasted or scanned app-connect ticket. Returns `null` on any306320 * malformed input — paste paths never throw. The ticket URL is a bearer@@ -1,4 +1,5 @@
11 import type { RuntimeStartupFailure } from "./startup-recovery.ts";2+import type { RuntimeStoreStatus } from "./store-status.ts";23 34 /**45 // Package-owned runtime diagnostics.@@ -8,6 +9,7 @@ import type { RuntimeStartupFailure } from "./startup-recovery.ts";
89 export type RuntimeDiagnostics = {910 storageState: "persistent-requested" | "persistent-driver-open" | "failed";1011 startupFailure: RuntimeStartupFailure | null;12+ storeStatus: RuntimeStoreStatus;1113 clientsCreated: number;1214 activeClients: number;1315 activeConsumers: number;@@ -27,6 +29,7 @@ export function createDiagnostics(): RuntimeDiagnostics {
2729 return {2830 storageState: "persistent-requested",2931 startupFailure: null,32+ storeStatus: { state: "unchecked", reason: "sync-not-connected" },3033 clientsCreated: 0,3134 activeClients: 0,3235 activeConsumers: 0,@@ -16,6 +16,7 @@ export type InspectorSnapshot = {
1616 sync: {1717 mode: "local-only" | "managed configured";1818 transport: "paused by inspector" | "live detail unavailable" | "not configured";19+ store: string;1920 pendingLocalWrites: number;2021 pendingGlobalWrites: number;2122 lastWrite: "none" | "local" | "global" | "failed";@@ -63,6 +64,7 @@ export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {
6364 { label: "Storage startup", value: snapshot.storage.startupFailure },6465 { label: "Sync mode", value: snapshot.sync.mode },6566 { label: "Transport", value: snapshot.sync.transport },67+ { label: "Store", value: snapshot.sync.store },6668 { label: "Pending lofi local", value: String(snapshot.sync.pendingLocalWrites) },6769 { label: "Pending lofi global", value: String(snapshot.sync.pendingGlobalWrites) },6870 { label: "Last write", value: snapshot.sync.lastWrite },@@ -20,6 +20,7 @@ const snapshot: InspectorSnapshot = {
2020 sync: {2121 mode: "managed configured",2222 transport: "live detail unavailable",23+ store: "no schema deployed — provision the store",2324 pendingLocalWrites: 1,2425 pendingGlobalWrites: 2,2526 lastWrite: "local",@@ -53,6 +54,10 @@ test("inspector rows retain truthful unavailable and pending states", () => {
5354 rows["Storage startup"] === "broker-incompatible",5455 "startup failure classification was omitted",5556 );57+ assert(58+ rows.Store === "no schema deployed — provision the store",59+ "the store preflight state was omitted",60+ );5661 assert(rows["Multi-tab role"] === "unavailable", "multi-tab role was fabricated");5762 assert(!JSON.stringify(rows).includes("secret"), "inspector rows exposed a secret-shaped field");5863 });@@ -9,6 +9,7 @@ import {
99 reloadBrowserRuntime,1010 subscribeRuntimeDiagnostics,1111 } from "./runtime.ts";12+import { describeStoreStatus } from "./store-status.ts";1213 import { isTransportPausedByInspector, setTransportPausedByInspector } from "./transport-gate.ts";1314 1415 export type LofiDevelopmentBridge = InspectorAdapter;@@ -59,6 +60,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
5960 transport: activeServerUrl()6061 ? isTransportPausedByInspector() ? "paused by inspector" : "live detail unavailable"6162 : "not configured",63+ store: describeStoreStatus(diagnostics.storeStatus),6264 pendingLocalWrites: diagnostics.pendingLocalWrites,6365 pendingGlobalWrites: diagnostics.pendingGlobalWrites,6466 lastWrite: diagnostics.lastWriteDurability,@@ -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();@@ -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+}@@ -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+});