clone

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

fix(runtime/preact): surface an unopenable sink record instead of silent local-only (#166)

FelineStateMachine committed eb6fdba
A persisted sync declaration whose device key is gone restored as
local-only with no signal, unlike a rejected ticket. The runtime now
remembers how the last restore resolved, and DeviceStatus renders the
unopenable state with the same recovery guidance: clear the sync
location and enroll a fresh ticket.
commit
eb6fdba0ccb5424a1ddcea1095cf44c3799b81b5
parent
020549cc84a0888280720a827b9a54320ce06504
author
FelineStateMachine <dami.etoile@gmail.com>
4 files changed, +56 -0
Mpackage/preact/DeviceStatus.tsx12++++++++++++
Mpackage/runtime/data-sink.ts24++++++++++++++++++++++++
Mpackage/runtime/data-sink_test.ts19+++++++++++++++++++
Mpackage/runtime/mod.ts1+
M package/preact/DeviceStatus.tsx +12 -0
@@ -14,6 +14,7 @@ import { describeSchemaCompat } from "../runtime/schema-compat.ts";
1414 import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";1515 import { useStorageFork } from "./use-storage-fork.ts";1616 import { readSession, type Session } from "../runtime/session.ts";17+import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";1718 import { describeStoreStatus } from "../runtime/store-status.ts";1819 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";1920
@@ -71,10 +72,13 @@ export default function DeviceStatus(): VNode {
7172 if (!report) return <p class="device-status">Checking device capabilities…</p>;7273 7374 const synced = Boolean(session?.syncAvailable);75+ const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";7476 const sinkState = session?.sink7577 ? session.sink.source === "declared"7678 ? `declared — ${session.sink.label ?? session.sink.host}`7779 : "configured (build default)"80+ : sinkUnopenable81+ ? "declared — record unopenable on this device"7882 : "not configured";7983 const syncState = session?.syncing8084 ? "syncing to your account"
@@ -146,6 +150,14 @@ export default function DeviceStatus(): VNode {
146150 ticket; local data is intact and pushes up on re-enrollment.147151 </p>148152 )}153+ {sinkUnopenable && (154+ <p>155+ A sync location is declared on this device, but its sealed record cannot be opened — the156+ device key that sealed it is gone, usually a cleared browser store. The device runs157+ local-only until re-enrollment. Clear the sync location and enroll a fresh ticket; local158+ data is intact and pushes up.159+ </p>160+ )}149161 {!synced && (150162 <p>151163 Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}
M package/runtime/data-sink.ts +24 -0
@@ -164,6 +164,21 @@ async function persistSealed(
164164 */165165 export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";166166 167+// The most recent restore's outcome, kept so status surfaces can distinguish168+// "no sync location" from "a sync location exists but this device cannot open169+// it". Cleared when the record is removed or successfully re-declared.170+let lastRestoreOutcome: SinkRestoreOutcome = "none";171+172+/**173+ * How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`174+ * answer means a declaration is persisted but no available key opens it — the175+ * device runs local-only until the sink is cleared and re-enrolled, and a176+ * status surface should say so rather than showing plain local-only.177+ */178+export function readSinkRestoreOutcome(): SinkRestoreOutcome {179+ return lastRestoreOutcome;180+}181+167182 /**168183 * Unseals the persisted declaration into memory. Boot awaits this before any169184 * sync decision (see `boot.ts`); tests and non-boot embedders call it
@@ -172,6 +187,11 @@ export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable"
172187 export async function restoreDeclaredSink(173188 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),174189 ): Promise<SinkRestoreOutcome> {190+ lastRestoreOutcome = await resolveRestore(keyStore);191+ return lastRestoreOutcome;192+}193+194+async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {175195 cachedSink = null;176196 if (typeof localStorage === "undefined") return "none";177197 let raw: string | null = null;
@@ -276,12 +296,16 @@ export async function declareDataSink(
276296 }277297 await persistSealed(declaration, keyStore);278298 cachedSink = declaration;299+ // The record now seals under an available key; an earlier unopenable300+ // restore no longer describes it.301+ lastRestoreOutcome = "restored";279302 return declaration;280303 }281304 282305 /** Removes the declared sink. Existing local data and elections are untouched. */283306 export function clearDeclaredSink(): void {284307 cachedSink = null;308+ lastRestoreOutcome = "none";285309 if (typeof localStorage === "undefined") return;286310 try {287311 localStorage.removeItem(sinkKey);
M package/runtime/data-sink_test.ts +19 -0
M package/runtime/mod.ts +1 -0