clone

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

fix(runtime): own broker incompatibility recovery

Dami committed e858f49
commit
e858f4969082a79b9e1b17fd3d016df45898e030
parent
a46ec5ac3704e1504f260f127557d0c44bca67c5
author
Dami <dami.etoile@gmail.com>
21 files changed, +420 -25
Mapps/reference/src/islands/TaskList.tsx5+-
Mapps/reference/src/islands/use-tasks.ts11+++-
Mapps/reference/src/styles/global.css12++++
Mdocs/troubleshooting.md14++++
Mpackage/astro/mod.ts2+
Mpackage/astro/mod_test.ts2+
Mpackage/preact/DeviceStatus.tsx18+++++-
Apackage/preact/RuntimeRecovery.tsx29+++++++++
Apackage/preact/RuntimeRecovery_test.tsx33++++++++++
Mpackage/preact/mod.ts1+
Mpackage/runtime/app.ts16++++-
Mpackage/runtime/config.ts5+-
Mpackage/runtime/diagnostics.ts4+
Mpackage/runtime/inspector.ts3+
Mpackage/runtime/inspector_test.ts11+++-
Mpackage/runtime/mod.ts8++-
Mpackage/runtime/probe.ts1+
Mpackage/runtime/runtime.ts69+++++++++++++++++----
Apackage/runtime/startup-recovery.ts99++++++++++++++++++++++++++++++
Apackage/runtime/startup-recovery_test.ts96+++++++++++++++++++++++++++++
Mpackage/testdata/starter.snapshot.json6+-
M apps/reference/src/islands/TaskList.tsx +3 -2
M apps/reference/src/islands/use-tasks.ts +10 -1
M apps/reference/src/styles/global.css +12 -0
M docs/troubleshooting.md +14 -0
M package/astro/mod.ts +2 -0
M package/astro/mod_test.ts +2 -0
M package/preact/DeviceStatus.tsx +17 -1
@@ -6,8 +6,13 @@ import { useDeviceCapabilities } from "./use-device-capabilities.ts";
66 import { settleUiMutation } from "../runtime/ui-mutation.ts";77 import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";88 import { PwaActions } from "./PwaActions.tsx";9-import { runtimeRecreatedEvent } from "../runtime/runtime.ts";9+import {10+ getRuntimeDiagnostics,11+ runtimeRecreatedEvent,12+ subscribeRuntimeDiagnostics,13+} from "../runtime/runtime.ts";1014 import { readSession, type Session } from "../runtime/session.ts";15+import { RuntimeRecovery } from "./RuntimeRecovery.tsx";1116 1217 /**1318 * The Device gate: every subsystem's live status, grouped by the system it
@@ -33,8 +38,13 @@ export default function DeviceStatus(): VNode {
3338 const { report, requestPersistence } = useDeviceCapabilities();3439 const [pwa, setPwa] = useState<PwaState>(getPwaState());3540 const [session, setSession] = useState<Session | null>(null);41+ const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());3642 3743 useEffect(() => subscribePwaState(setPwa), []);44+ useEffect(45+ () => subscribeRuntimeDiagnostics(() => setRuntimeDiagnostics(getRuntimeDiagnostics())),46+ [],47+ );3848 useEffect(() => {3949 const refresh = () => setSession(readSession());4050 refresh();
@@ -80,7 +90,13 @@ export default function DeviceStatus(): VNode {
8090 <Row label="Web Locks" value={available(report.webLocks)} />8191 <Row label="MessageChannel" value={available(report.messageChannel)} />8292 <Row label="Storage persistence" value={report.persistentPermission} />93+ <Row94+ label="Runtime startup"95+ value={runtimeDiagnostics.startupFailure?.code ??96+ (runtimeDiagnostics.storageState === "persistent-driver-open" ? "ready" : "opening")}97+ />8398 </dl>99+ <RuntimeRecovery failure={runtimeDiagnostics.startupFailure} />84100 <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>85101 Request storage persistence86102 </button>
A package/preact/RuntimeRecovery.tsx +29 -0
A package/preact/RuntimeRecovery_test.tsx +33 -0
M package/preact/mod.ts +1 -0
M package/runtime/app.ts +13 -3
@@ -25,6 +25,14 @@ function slot(): AppSlot {
2525 return browserGlobal[slotName];2626 }2727 28+/** Raised when package runtime startup cannot resolve valid author-owned app configuration. */29+export class LofiConfigurationError extends Error {30+ /** Stable error class name for diagnostics and error boundaries. */31+ override readonly name = "LofiConfigurationError";32+ /** Stable category for runtime startup classification. */33+ readonly code = "configuration-error";34+}35+2836 /**2937 * Defines the application-facing values the versioned runtime needs.3038 *
@@ -34,8 +42,10 @@ function slot(): AppSlot {
3442 export function defineLofiApp<const Schema>(3543 config: LofiAppConfig<Schema>,3644 ): LofiAppConfig<Schema> {37- if (!config.name.trim()) throw new Error("lofi app name must not be empty");38- if (!config.databaseName.trim()) throw new Error("lofi database name must not be empty");45+ if (!config.name.trim()) throw new LofiConfigurationError("lofi app name must not be empty");46+ if (!config.databaseName.trim()) {47+ throw new LofiConfigurationError("lofi database name must not be empty");48+ }3949 slot().config = config;4050 return config;4151 }
@@ -44,7 +54,7 @@ export function defineLofiApp<const Schema>(
4454 export function getLofiApp(): LofiAppConfig {4555 const config = slot().config;4656 if (!config) {47- throw new Error(57+ throw new LofiConfigurationError(4858 "lofi app is not defined; export defineLofiApp({...}) from src/app.ts before booting the runtime",4959 );5060 }
M package/runtime/config.ts +4 -1
M package/runtime/diagnostics.ts +4 -0
M package/runtime/inspector.ts +3 -0
M package/runtime/inspector_test.ts +10 -1
M package/runtime/mod.ts +7 -1
M package/runtime/probe.ts +1 -0
M package/runtime/runtime.ts +57 -12
@@ -20,6 +20,11 @@ import {
2020 type SerializedResourceState,2121 shutdownResource,2222 } from "./resource-lifecycle.ts";23+import {24+ createBrokerIncompatibilityHandler,25+ runRuntimeStartup,26+ type RuntimeStartupFailure,27+} from "./startup-recovery.ts";2328 2429 /** The shared, lazily opened Jazz client and its application-facing adapters. */2530 export type LofiRuntime = {
@@ -64,6 +69,18 @@ function notifyDiagnostics(state = slot()): void {
6469 for (const listener of state.diagnosticListeners) listener();6570 }6671 72+function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure): void {73+ const previous = state.diagnostics.startupFailure;74+ state.diagnostics.storageState = "failed";75+ state.diagnostics.startupFailure = failure;76+ if (77+ previous?.code !== failure.code || previous.runtimeMode !== failure.runtimeMode ||78+ previous.message !== failure.message79+ ) {80+ notifyDiagnostics(state);81+ }82+}83+6784 // Local-first: a random per-device secret, created on demand and cached — so6885 // the account opens immediately on first boot, offline, with no ceremony. The6986 // same secret opens the same account whether or not sync is attached, so a
@@ -98,11 +115,30 @@ async function awaitLocalReady(db: Db): Promise<void> {
98115 if (firstTable) await db.all(firstTable.where({}) as never, { tier: "local" });99116 }100117 101-async function migrateLocalRows(db: Db, secret: string, namespace: string): Promise<void> {118+async function migrateLocalRows(119+ state: RuntimeSlot,120+ db: Db,121+ secret: string,122+ namespace: string,123+): Promise<void> {102124 if (typeof localStorage === "undefined" || localStorage.getItem(migrateLocalRowsKey) !== "1") {103125 return;104126 }105- const local = await createDb(databaseConfig(secret, namespace, "local"));127+ const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);128+ const local = await runRuntimeStartup(129+ "local",130+ () =>131+ createDb(132+ databaseConfig(133+ secret,134+ namespace,135+ "local",136+ false,137+ createBrokerIncompatibilityHandler("local", record),138+ ),139+ ),140+ record,141+ );106142 await awaitLocalReady(local);107143 for (const table of runtimeTables()) {108144 const rows = await local.all(table.where({}) as never, { tier: "local" }) as Array<
@@ -124,33 +160,42 @@ async function migrateLocalRows(db: Db, secret: string, namespace: string): Prom
124160 }125161 126162 async function createClient(state: RuntimeSlot): Promise<Db> {127- try {163+ const managedNamespace = typeof localStorage !== "undefined" &&164+ localStorage.getItem(managedRuntimeKey) === "1";165+ const connect = syncing();166+ const runtimeMode = managedNamespace || connect ? "managed" : "local";167+ const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);168+ state.diagnostics.storageState = "persistent-requested";169+ state.diagnostics.startupFailure = null;170+ notifyDiagnostics(state);171+ return await runRuntimeStartup(runtimeMode, async () => {128172 assertDurableBrowser();129173 const secret = await resolveAccountSecret();130174 const namespace = await accountNamespace(secret);131- const managedNamespace = typeof localStorage !== "undefined" &&132- localStorage.getItem(managedRuntimeKey) === "1";133- const connect = syncing();134175 const db = await createDb(135- databaseConfig(secret, namespace, managedNamespace || connect ? "managed" : "local", connect),176+ databaseConfig(177+ secret,178+ namespace,179+ runtimeMode,180+ connect,181+ createBrokerIncompatibilityHandler(runtimeMode, record),182+ ),136183 );137184 // Browser `createDb()` returns before its persistent worker bridge has138185 // necessarily attached. A local-tier query is the public readiness barrier;139186 // without it, an immediate post-replacement mutation can see no attainable140187 // durability tier and be rejected even though OPFS is configured.141188 await awaitLocalReady(db);142- if (syncing()) await migrateLocalRows(db, secret, namespace);189+ if (syncing()) await migrateLocalRows(state, db, secret, namespace);143190 state.principal = db.getAuthState().session?.user_id ?? null;144191 state.serverConfigured = connect;145192 state.diagnostics.storageState = "persistent-driver-open";193+ state.diagnostics.startupFailure = null;146194 state.diagnostics.clientsCreated += 1;147195 state.diagnostics.activeClients += 1;148196 notifyDiagnostics(state);149197 return db;150- } catch (error) {151- state.diagnostics.storageState = "failed";152- throw error;153- }198+ }, record);154199 }155200 156201 async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {
A package/runtime/startup-recovery.ts +99 -0
@@ -0,0 +1,99 @@
1+import {2+ IncompatibleBrowserBrokerConfigurationError,3+ type IncompatibleBrowserBrokerConfigurationHandler,4+} from "jazz-tools";5+import { LofiConfigurationError } from "./app.ts";6+import { DurableStorageUnsupportedError } from "./device-capabilities.ts";7+8+/** Stable categories for failures that prevent Lofi's persistent runtime from opening. */9+export type RuntimeStartupFailureCode =10+ | "broker-incompatible"11+ | "configuration-error"12+ | "storage-startup-failed"13+ | "unsupported-capabilities";14+15+/** Non-sensitive runtime context retained for diagnostics and recovery UI. */16+export type RuntimeStartupFailure = {17+ code: RuntimeStartupFailureCode;18+ runtimeMode: "local" | "managed";19+ message: string;20+};21+22+const startupMessages: Record<RuntimeStartupFailureCode, string> = {23+ "broker-incompatible":24+ "Another tab for this app is running an incompatible version. Close every other app tab, then reload this tab.",25+ "configuration-error":26+ "The Lofi runtime configuration is invalid. Run deno task doctor, fix the first blocker, then reload.",27+ "storage-startup-failed":28+ "Durable storage could not start. Run deno task doctor, fix the first blocker, then reload.",29+ "unsupported-capabilities":30+ "This browser cannot provide Lofi's durable storage requirements. Open the stable HTTPS app URL in a supported browser.",31+};32+33+/** Lofi-owned error boundary for a rejected persistent runtime startup. */34+export class RuntimeStartupError extends Error {35+ /** Stable error class name for diagnostics and UI boundaries. */36+ override readonly name = "RuntimeStartupError";37+ /** Stable public category without vendor-specific error details. */38+ readonly code: RuntimeStartupFailureCode;39+ /** Non-sensitive failure snapshot retained for diagnostics. */40+ readonly failure: RuntimeStartupFailure;41+42+ /** Creates a stable public rejection while retaining the original error as its cause. */43+ constructor(failure: RuntimeStartupFailure, cause?: unknown) {44+ super(failure.message, cause === undefined ? undefined : { cause });45+ this.code = failure.code;46+ this.failure = failure;47+ }48+}49+50+/** Maps vendor, capability, configuration, and fallback failures onto stable Lofi categories. */51+export function classifyRuntimeStartupFailure(52+ error: unknown,53+ runtimeMode: RuntimeStartupFailure["runtimeMode"],54+): RuntimeStartupFailure {55+ const code: RuntimeStartupFailureCode =56+ error instanceof IncompatibleBrowserBrokerConfigurationError57+ ? "broker-incompatible"58+ : error instanceof DurableStorageUnsupportedError59+ ? "unsupported-capabilities"60+ : error instanceof LofiConfigurationError61+ ? "configuration-error"62+ : "storage-startup-failed";63+ return { code, runtimeMode, message: startupMessages[code] };64+}65+66+/**67+ * Creates the package-owned Jazz override. It records the stable failure but68+ * deliberately never confirms or reloads; the rejected createDb call remains69+ * the startup boundary and recovery stays an explicit user action.70+ */71+export function createBrokerIncompatibilityHandler(72+ runtimeMode: RuntimeStartupFailure["runtimeMode"],73+ record: (failure: RuntimeStartupFailure) => void,74+): IncompatibleBrowserBrokerConfigurationHandler {75+ return (error) => record(classifyRuntimeStartupFailure(error, runtimeMode));76+}77+78+/** Runs one runtime startup attempt, retaining and throwing only Lofi-owned classification. */79+export async function runRuntimeStartup<T>(80+ runtimeMode: RuntimeStartupFailure["runtimeMode"],81+ start: () => Promise<T>,82+ record: (failure: RuntimeStartupFailure) => void,83+): Promise<T> {84+ try {85+ return await start();86+ } catch (error) {87+ if (error instanceof RuntimeStartupError) throw error;88+ const failure = classifyRuntimeStartupFailure(error, runtimeMode);89+ record(failure);90+ throw new RuntimeStartupError(failure, error);91+ }92+}93+94+/** Performs the explicit navigation required after closing incompatible app tabs. */95+export function reloadAfterRuntimeStartupFailure(96+ reload: () => void = () => globalThis.location.reload(),97+): void {98+ reload();99+}
A package/runtime/startup-recovery_test.ts +96 -0
@@ -0,0 +1,96 @@
1+import { IncompatibleBrowserBrokerConfigurationError } from "jazz-tools";2+import { defineLofiApp, LofiConfigurationError } from "./app.ts";3+import { databaseConfig } from "./config.ts";4+import { DurableStorageUnsupportedError } from "./device-capabilities.ts";5+import {6+ classifyRuntimeStartupFailure,7+ createBrokerIncompatibilityHandler,8+ reloadAfterRuntimeStartupFailure,9+ runRuntimeStartup,10+ RuntimeStartupError,11+ type RuntimeStartupFailure,12+} from "./startup-recovery.ts";13+14+function assert(condition: unknown, message: string): asserts condition {15+ if (!condition) throw new Error(message);16+}17+18+defineLofiApp({19+ name: "startup-recovery-test",20+ databaseName: "startup-recovery",21+ schema: {},22+ storage: "durable",23+ sync: { adapter: "jazz" },24+});25+26+for (const mode of ["local", "managed"] as const) {27+ Deno.test(`persistent ${mode} configuration owns incompatible broker recovery`, async () => {28+ const failures: RuntimeStartupFailure[] = [];29+ const handler = createBrokerIncompatibilityHandler(mode, (failure) => failures.push(failure));30+ const config = databaseConfig("test-secret", "account", mode, false, handler);31+ assert(32+ config.onIncompatibleBrowserBrokerConfiguration === handler,33+ `${mode} persistent configuration omitted the package callback`,34+ );35+ const vendorError = new IncompatibleBrowserBrokerConfigurationError("vendor detail");36+37+ let rejection: unknown;38+ try {39+ await runRuntimeStartup(mode, () => {40+ config.onIncompatibleBrowserBrokerConfiguration?.(vendorError);41+ return Promise.reject(vendorError);42+ }, (failure) => failures.push(failure));43+ } catch (error) {44+ rejection = error;45+ }46+47+ assert(rejection instanceof RuntimeStartupError, "startup rejection was swallowed or leaked");48+ assert(49+ rejection.code === "broker-incompatible",50+ "vendor error leaked past stable classification",51+ );52+ assert(rejection.failure.runtimeMode === mode, "runtime mode was classified incorrectly");53+ assert(54+ failures.every((failure) =>55+ failure.code === "broker-incompatible" && failure.runtimeMode === mode56+ ),57+ "callback exposed an unstable failure",58+ );59+ assert(60+ !rejection.message.includes("vendor detail"),61+ "vendor callback detail reached public error",62+ );63+ });64+}65+66+Deno.test("startup failures distinguish capability, configuration, and storage errors", () => {67+ const unsupported = new DurableStorageUnsupportedError({68+ secureContext: true,69+ opfs: false,70+ sharedWorker: true,71+ webLocks: true,72+ messageChannel: true,73+ durableDriverSupported: false,74+ webAuthn: true,75+ prf: "unknown",76+ displayMode: "browser",77+ });78+ const cases = [79+ [unsupported, "unsupported-capabilities"],80+ [new LofiConfigurationError("bad author config"), "configuration-error"],81+ [new Error("raw storage error"), "storage-startup-failed"],82+ ] as const;83+ for (const [error, expected] of cases) {84+ const failure = classifyRuntimeStartupFailure(error, "local");85+ assert(failure.code === expected, `${expected} was classified as ${failure.code}`);86+ assert(!failure.message.includes(error.message), `${expected} exposed raw error detail`);87+ }88+});89+90+Deno.test("broker recovery reloads only after the explicit action", () => {91+ let reloads = 0;92+ const reload = () => reloads += 1;93+ assert(reloads === 0, "recovery reloaded before user action");94+ reloadAfterRuntimeStartupFailure(reload);95+ assert(Number(reloads) === 1, "explicit recovery action did not reload exactly once");96+});
M package/testdata/starter.snapshot.json +3 -3
@@ -13,13 +13,13 @@
1313 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1414 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1515 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",16- "src/islands/TaskList.tsx": "0bd514b26318afcb91d18c00349b1c8b2610bf16f72b8fdc66795d8b6ed766d2",17- "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",16+ "src/islands/TaskList.tsx": "64275d107c2ff725c514d951bd577990e3aeab4e2dcb8f182ded62ebd75f38e7",17+ "src/islands/use-tasks.ts": "6ee49d90580ab9b6810d20617504e0f0147d568d42ab2b381caafb3ab23f8717",1818 "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1919 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",2020 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",2121 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",22- "src/styles/global.css": "d298ccc0db2749367232e0ad3e759698e98b04ea9c4516f1f5168ed67f43b93f",22+ "src/styles/global.css": "da8594026ea8420d00c58549b9b38040b4456f75615959ca2e94997b18be1997",2323 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",2424 "tests/author-boundary_test.ts": "303691da91fd4d13ed2dca195b7745c5c106bcd0837338e632469dfec032d71b",2525 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",