fix(runtime): own broker incompatibility recovery
- commit
- 654cf1041c3896ce1e130a2652fa05733e6c9fed
- parent
- a46ec5ac3704e1504f260f127557d0c44bca67c5
- parent
- e858f4969082a79b9e1b17fd3d016df45898e030
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 5 | +- |
| M | apps/ | 11 | +++- |
| M | apps/ | 12 | ++++ |
| M | docs/ | 14 | ++++ |
| M | package/ | 2 | + |
| M | package/ | 2 | + |
| M | package/ | 18 | +++++- |
| A | package/ | 29 | +++++++++ |
| A | package/ | 33 | ++++++++++ |
| M | package/ | 1 | + |
| M | package/ | 16 | ++++- |
| M | package/ | 5 | +- |
| M | package/ | 4 | + |
| M | package/ | 3 | + |
| M | package/ | 11 | +++- |
| M | package/ | 8 | ++- |
| M | package/ | 1 | + |
| M | package/ | 69 | +++++++++++++++++---- |
| A | package/ | 99 | ++++++++++++++++++++++++++++++ |
| A | package/ | 96 | +++++++++++++++++++++++++++++ |
| M | package/ | 6 | +- |
@@ -8,7 +8,7 @@ import { useTasks } from "./use-tasks.ts";
88 * package-owned runtime stays the same.99 */1010 export default function TaskList() {11- const { status, error, durability, tasks, create, setCompleted } = useTasks();11+ const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();1212 const [text, setText] = useState("");1313 1414 return (@@ -39,7 +39,8 @@ export default function TaskList() {
3939 </form>4040 <p class="state" role="status">4141 {status === "loading" && "Opening persistent storage…"}42- {status === "error" && `Write failed: ${error}`}42+ {status === "error" &&43+ `${failureKind === "startup" ? "Startup failed" : "Write failed"}: ${error}`}4344 {status === "ready" && `${tasks.length} item(s) · ${4445 durability === "global"4546 ? "synced to your account"@@ -23,6 +23,7 @@ const initial: TableSnapshot<Task> = {
2323 2424 export function useTasks() {2525 const [snapshot, setSnapshot] = useState(initial);26+ const [startupFailed, setStartupFailed] = useState(false);2627 2728 useEffect(() => {2829 let cancelled = false;@@ -36,6 +37,7 @@ export function useTasks() {
3637 unsubscribe?.();3738 unsubscribe = undefined;3839 setSnapshot(initial);40+ setStartupFailed(false);3941 void getRuntime().then((runtime) => {4042 if (cancelled || generation !== connectionGeneration) return;4143 const store = runtime.store(tasksTable);@@ -44,6 +46,7 @@ export function useTasks() {
4446 update();4547 }, (error) => {4648 if (cancelled || generation !== connectionGeneration) return;49+ setStartupFailed(true);4750 setSnapshot({4851 ...initial,4952 status: "error",@@ -72,5 +75,11 @@ export function useTasks() {
7275 await (await store()).update(id, { completed });7376 }, [store]);7477 75- return { ...snapshot, tasks: snapshot.rows, create, setCompleted };78+ return {79+ ...snapshot,80+ tasks: snapshot.rows,81+ failureKind: startupFailed ? "startup" as const : "write" as const,82+ create,83+ setCompleted,84+ };7685 }@@ -108,6 +108,18 @@ h1 {
108108 margin: 0 0 0.15rem;109109 }110110 111+.runtime-recovery {112+ margin: 1rem 0;113+ padding: 1rem;114+ border: 1px solid var(--accent, currentColor);115+ border-radius: 0.75rem;116+}117+118+.runtime-recovery h4,119+.runtime-recovery p {120+ margin-top: 0;121+}122+111123 .device-hook {112124 color: #9bc9b1;113125 font-size: 0.8rem;@@ -52,6 +52,20 @@ of silently switching to memory-only data.
5252 - Treat clearing site data as destructive unless the account has synced and the recovery phrase is5353 safe.5454 55+## Another tab is running an incompatible app version56+57+Lofi stops persistent runtime startup when another tab for the same app is using an incompatible58+browser broker configuration. This is reported as `broker-incompatible` in runtime diagnostics in59+both local and managed mode.60+61+1. Close every other tab or installed-app window for this app.62+2. Return to the tab showing the recovery notice.63+3. Select **Reload app** once.64+65+The package does not take over the existing broker, fall back to memory, or automatically enter a66+reload loop. Do not clear site data for this condition: closing the incompatible tabs and explicitly67+reloading creates the clean document boundary the persistent driver requires.68+5569 ## A task disappeared after changing app configuration5670 5771 Check whether `databaseName` or the managed `JAZZ_APP_ID` changed. Both participate in the durable@@ -36,6 +36,7 @@ const runtimeFiles = [
3636 "resource-lifecycle.ts",3737 "runtime.ts",3838 "session.ts",39+ "startup-recovery.ts",3940 "table-store.ts",4041 "ui-mutation.ts",4142 ] as const;@@ -53,6 +54,7 @@ const preactFiles = [
5354 "DeviceStatus.tsx",5455 "mod.ts",5556 "PwaActions.tsx",57+ "RuntimeRecovery.tsx",5658 "use-device-capabilities.ts",5759 ] as const;5860 @@ -25,7 +25,9 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
2525 if (!source.includes(expected)) throw new Error(`generated config omitted ${expected}`);2626 }2727 await Deno.stat(`${root}/.lofi/package/preact/DeviceStatus.tsx`);28+ await Deno.stat(`${root}/.lofi/package/preact/RuntimeRecovery.tsx`);2829 await Deno.stat(`${root}/.lofi/package/runtime/passkey-recovery.ts`);30+ await Deno.stat(`${root}/.lofi/package/runtime/startup-recovery.ts`);2931 await Deno.stat(`${root}/.lofi/package/access/mod.ts`);3032 } finally {3133 await Deno.remove(root, { recursive: true });@@ -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>@@ -0,0 +1,29 @@
1+import type { JSX } from "preact";2+import {3+ reloadAfterRuntimeStartupFailure,4+ type RuntimeStartupFailure,5+} from "../runtime/startup-recovery.ts";6+7+/** Inputs for Lofi's explicit persistent-runtime recovery action. */8+export type RuntimeRecoveryProps = {9+ failure: RuntimeStartupFailure | null;10+ reload?: () => void;11+};12+13+/** Renders recovery only when another tab is running an incompatible broker version. */14+export function RuntimeRecovery({ failure, reload }: RuntimeRecoveryProps): JSX.Element | null {15+ if (failure?.code !== "broker-incompatible") return null;16+ return (17+ <div class="runtime-recovery" role="alert">18+ <h4>Close other app tabs</h4>19+ <p>{failure.message}</p>20+ <p>This tab will not reload or retry automatically.</p>21+ <button22+ type="button"23+ onClick={() => reloadAfterRuntimeStartupFailure(reload)}24+ >25+ Reload app26+ </button>27+ </div>28+ );29+}@@ -0,0 +1,33 @@
1+import { render } from "npm:preact-render-to-string@6.7.0";2+import { RuntimeRecovery } from "./RuntimeRecovery.tsx";3+4+Deno.test("RuntimeRecovery explains the explicit incompatible-broker action", () => {5+ const html = render(6+ <RuntimeRecovery7+ failure={{8+ code: "broker-incompatible",9+ runtimeMode: "managed",10+ message:11+ "Another tab for this app is running an incompatible version. Close every other app tab, then reload this tab.",12+ }}13+ />,14+ );15+ for (16+ const text of ["Close other app tabs", "will not reload or retry automatically", "Reload app"]17+ ) {18+ if (!html.includes(text)) throw new Error(`recovery UI omitted: ${text}`);19+ }20+});21+22+Deno.test("RuntimeRecovery stays hidden for startup failures without a browser-tab action", () => {23+ const html = render(24+ <RuntimeRecovery25+ failure={{26+ code: "unsupported-capabilities",27+ runtimeMode: "local",28+ message: "unsupported",29+ }}30+ />,31+ );32+ if (html) throw new Error("generic startup failure rendered broker recovery");33+});@@ -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 }@@ -1,6 +1,6 @@
11 // Package-owned environment and adapter configuration.22 3-import type { DbConfig } from "jazz-tools";3+import type { DbConfig, IncompatibleBrowserBrokerConfigurationHandler } from "jazz-tools";44 import { getLofiApp } from "./app.ts";55 66 declare const __LOFI_JAZZ_APP_ID__: string;@@ -54,6 +54,8 @@ export function databaseConfig(
5454 accountNamespace: string,5555 mode: "local" | "managed" = syncing() ? "managed" : "local",5656 connect = mode === "managed" && syncing(),57+ onIncompatibleBrowserBrokerConfiguration: IncompatibleBrowserBrokerConfigurationHandler =58+ () => {},5759 ): DbConfig {5860 const app = getLofiApp();5961 return {@@ -66,5 +68,6 @@ export function databaseConfig(
6668 type: "persistent",6769 dbName: `${app.databaseName}-${appId}-${accountNamespace}-${mode}`,6870 },71+ onIncompatibleBrowserBrokerConfiguration,6972 };7073 }@@ -1,3 +1,5 @@
1+import type { RuntimeStartupFailure } from "./startup-recovery.ts";2+13 /**24 // Package-owned runtime diagnostics.35 * Runtime-owned observability counters. These describe the framework's storage,@@ -5,6 +7,7 @@
57 */68 export type RuntimeDiagnostics = {79 storageState: "persistent-requested" | "persistent-driver-open" | "failed";10+ startupFailure: RuntimeStartupFailure | null;811 clientsCreated: number;912 activeClients: number;1013 activeConsumers: number;@@ -23,6 +26,7 @@ export type RuntimeDiagnostics = {
2326 export function createDiagnostics(): RuntimeDiagnostics {2427 return {2528 storageState: "persistent-requested",29+ startupFailure: null,2630 clientsCreated: 0,2731 activeClients: 0,2832 activeConsumers: 0,@@ -1,5 +1,6 @@
11 export const DEVELOPMENT_INSPECTOR_MARKER = "lofi-development-inspector";22 // Package-owned development inspector.3+import type { RuntimeStartupFailureCode } from "./startup-recovery.ts";34 45 export type InspectorSnapshot = {56 identity: {@@ -10,6 +11,7 @@ export type InspectorSnapshot = {
1011 driver: "persistent requested" | "persistent open" | "failed";1112 persistence: "granted" | "not granted" | "unavailable" | "error";1213 fallback: "none" | "explicit memory" | "unavailable";14+ startupFailure: RuntimeStartupFailureCode | "none";1315 };1416 sync: {1517 mode: "local-only" | "managed configured";@@ -58,6 +60,7 @@ export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {
5860 { label: "Storage driver", value: snapshot.storage.driver },5961 { label: "Persistence", value: snapshot.storage.persistence },6062 { label: "Storage fallback", value: snapshot.storage.fallback },63+ { label: "Storage startup", value: snapshot.storage.startupFailure },6164 { label: "Sync mode", value: snapshot.sync.mode },6265 { label: "Transport", value: snapshot.sync.transport },6366 { label: "Pending lofi local", value: String(snapshot.sync.pendingLocalWrites) },@@ -11,7 +11,12 @@ function assert(condition: unknown, message: string): asserts condition {
1111 1212 const snapshot: InspectorSnapshot = {1313 identity: { state: "device-local key active", backup: "recovery phrase" },14- storage: { driver: "persistent open", persistence: "granted", fallback: "none" },14+ storage: {15+ driver: "persistent open",16+ persistence: "granted",17+ fallback: "none",18+ startupFailure: "broker-incompatible",19+ },1520 sync: {1621 mode: "managed configured",1722 transport: "live detail unavailable",@@ -44,6 +49,10 @@ test("inspector rows retain truthful unavailable and pending states", () => {
4449 assert(rows.Transport === "live detail unavailable", "transport precision was fabricated");4550 assert(rows["Pending lofi local"] === "1", "local pending work was omitted");4651 assert(rows["Pending lofi global"] === "2", "global pending work was omitted");52+ assert(53+ rows["Storage startup"] === "broker-incompatible",54+ "startup failure classification was omitted",55+ );4756 assert(rows["Multi-tab role"] === "unavailable", "multi-tab role was fabricated");4857 assert(!JSON.stringify(rows).includes("secret"), "inspector rows exposed a secret-shaped field");4958 });@@ -12,7 +12,7 @@
1212 * 1313 */1414 15-export { defineLofiApp, type LofiAppConfig } from "./app.ts";15+export { defineLofiApp, type LofiAppConfig, LofiConfigurationError } from "./app.ts";1616 export {1717 type AuthCapability,1818 type AuthDependencies,@@ -69,6 +69,12 @@ export {
6969 shutdownRuntime,7070 subscribeRuntimeDiagnostics,7171 } from "./runtime.ts";72+export {73+ reloadAfterRuntimeStartupFailure,74+ RuntimeStartupError,75+ type RuntimeStartupFailure,76+ type RuntimeStartupFailureCode,77+} from "./startup-recovery.ts";7278 export {7379 AccountReplacementError,7480 type AccountReplacementOptions,@@ -54,6 +54,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
5454 ? "not granted"5555 : device.persistentPermission,5656 fallback: diagnostics.storageState === "failed" ? "unavailable" : "none",57+ startupFailure: diagnostics.startupFailure?.code ?? "none",5758 },5859 sync: {5960 mode: serverUrl ? "managed configured" : "local-only",@@ -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> {@@ -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+}@@ -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+});@@ -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",