clone

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

fix: restore enrolled sync before runtime startup (#188)

FelineStateMachine committed 8d803da
commit
8d803da49be6031420b9a3c813e5cd3445cf6168
parent
1a7e848ee93aa5b88b78b261247a4689265e9f56
author
FelineStateMachine <dami.etoile@gmail.com>
7 files changed, +145 -27
Mapps/demo/overlay/src/islands/IncidentBoard.tsx1+
Mpackage/preact/DeviceStatus.tsx9+++-
Mpackage/runtime/boot.ts8+---
Mpackage/runtime/data-sink.ts71+++++++++++++++++++++++-------
Mpackage/runtime/data-sink_test.ts68+++++++++++++++++++++++++++++
Mpackage/runtime/runtime.ts11+++-
Mtools/demo_overlay_test.ts4++
M apps/demo/overlay/src/islands/IncidentBoard.tsx +1 -0
M package/preact/DeviceStatus.tsx +8 -1
M package/runtime/boot.ts +2 -6
M package/runtime/data-sink.ts +54 -17
@@ -112,10 +112,27 @@ const sinkKey = `lofi:data-sink:${anchorAppId}`;
112112 const sinkPurpose = `lofi:data-sink:${anchorAppId}`;113113 const sinkDeviceKeyId = `data-sink:${anchorAppId}`;114114 115-// The declaration in effect for this document, populated by116-// restoreDeclaredSink at boot and by successful declarations afterwards.117-// Synchronous readers answer from here; storage holds only the envelope.118-let cachedSink: DataSinkDeclaration | null = null;115+// Astro islands can bundle this module more than once in the same document.116+// Keep the restored declaration on globalThis so every island observes the117+// boot script's restore and a declaration made by any sibling island. Storage118+// still holds only the sealed envelope.119+type DataSinkSlot = {120+ cachedSink: DataSinkDeclaration | null;121+ lastRestoreOutcome: SinkRestoreOutcome;122+ restorePromise: Promise<SinkRestoreOutcome> | null;123+};124+125+const dataSinkSlotName = "__LOFI_ALPHA53_DATA_SINK__";126+const browserGlobal = globalThis as typeof globalThis & { [dataSinkSlotName]?: DataSinkSlot };127+128+function dataSinkSlot(): DataSinkSlot {129+ browserGlobal[dataSinkSlotName] ??= {130+ cachedSink: null,131+ lastRestoreOutcome: "none",132+ restorePromise: null,133+ };134+ return browserGlobal[dataSinkSlotName];135+}119136 120137 function validateDeclaration(value: unknown): DataSinkDeclaration | null {121138 if (value === null || typeof value !== "object") return null;
@@ -167,8 +184,6 @@ export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable"
167184 // The most recent restore's outcome, kept so status surfaces can distinguish168185 // "no sync location" from "a sync location exists but this device cannot open169186 // it". Cleared when the record is removed or successfully re-declared.170-let lastRestoreOutcome: SinkRestoreOutcome = "none";171-172187 /**173188 * How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`174189 * answer means a declaration is persisted but no available key opens it — the
@@ -176,7 +191,7 @@ let lastRestoreOutcome: SinkRestoreOutcome = "none";
176191 * status surface should say so rather than showing plain local-only.177192 */178193 export function readSinkRestoreOutcome(): SinkRestoreOutcome {179- return lastRestoreOutcome;194+ return dataSinkSlot().lastRestoreOutcome;180195 }181196 182197 /**
@@ -187,12 +202,32 @@ export function readSinkRestoreOutcome(): SinkRestoreOutcome {
187202 export async function restoreDeclaredSink(188203 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),189204 ): Promise<SinkRestoreOutcome> {190- lastRestoreOutcome = await resolveRestore(keyStore);191- return lastRestoreOutcome;205+ const state = dataSinkSlot();206+ state.lastRestoreOutcome = await resolveRestore(keyStore);207+ return state.lastRestoreOutcome;208+}209+210+/**211+ * Restores the document's sealed sink exactly once before the first runtime212+ * opens. App islands and the boot script can start concurrently; sharing this213+ * promise prevents an eager island from creating a local-only client while214+ * boot is still unsealing a configured sink.215+ */216+export function ensureDeclaredSinkRestored(217+ keyStore: DeviceKeyStore = defaultDeviceKeyStore(),218+): Promise<SinkRestoreOutcome> {219+ const state = dataSinkSlot();220+ state.restorePromise ??= restoreDeclaredSink(keyStore).catch(() => {221+ state.cachedSink = null;222+ state.lastRestoreOutcome = "none";223+ return "none";224+ });225+ return state.restorePromise;192226 }193227 194228 async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {195- cachedSink = null;229+ const state = dataSinkSlot();230+ state.cachedSink = null;196231 if (typeof localStorage === "undefined") return "none";197232 let raw: string | null = null;198233 try {
@@ -213,7 +248,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
213248 // cleartext bearer URL leaves storage on the first boot that can.214249 const sink = validateDeclaration(record.sink);215250 if (!sink) return "none";216- cachedSink = sink;251+ state.cachedSink = sink;217252 await persistSealed(sink, keyStore);218253 return "migrated";219254 }
@@ -224,7 +259,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
224259 const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));225260 const sink = validateDeclaration(payload);226261 if (!sink) return "unopenable";227- cachedSink = sink;262+ state.cachedSink = sink;228263 return "restored";229264 } catch (error) {230265 if (error instanceof EnvelopeError) return "unopenable";
@@ -240,7 +275,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
240275 * (tests, embedders) await {@link restoreDeclaredSink} first.241276 */242277 export function readDeclaredSink(): DataSinkDeclaration | null {243- return cachedSink;278+ return dataSinkSlot().cachedSink;244279 }245280 246281 function assertHttpServerUrl(serverUrl: string): void {
@@ -295,17 +330,19 @@ export async function declareDataSink(
295330 );296331 }297332 await persistSealed(declaration, keyStore);298- cachedSink = declaration;333+ const state = dataSinkSlot();334+ state.cachedSink = declaration;299335 // The record now seals under an available key; an earlier unopenable300336 // restore no longer describes it.301- lastRestoreOutcome = "restored";337+ state.lastRestoreOutcome = "restored";302338 return declaration;303339 }304340 305341 /** Removes the declared sink. Existing local data and elections are untouched. */306342 export function clearDeclaredSink(): void {307- cachedSink = null;308- lastRestoreOutcome = "none";343+ const state = dataSinkSlot();344+ state.cachedSink = null;345+ state.lastRestoreOutcome = "none";309346 if (typeof localStorage === "undefined") return;310347 try {311348 localStorage.removeItem(sinkKey);
M package/runtime/data-sink_test.ts +68 -0
@@ -8,6 +8,7 @@ import {
88 clearDeclaredSink,99 declareDataSink,1010 declareSinkFromTicket,11+ ensureDeclaredSinkRestored,1112 isDataSinkError,1213 parseSyncTicket,1314 readDeclaredSink,
@@ -105,6 +106,73 @@ test(
105106 }),106107 );107108 109+test(110+ "boot and app islands single-flight the sealed sink restore",111+ withCleanState(async () => {112+ const keyStore = memoryDeviceKeyStore();113+ await declareDataSink({114+ appId: ticketAppId,115+ serverUrl: "https://node.example:4802",116+ label: "boot race",117+ }, keyStore);118+ const stored = localStorage.getItem(sinkKey);119+ assert(stored !== null, "the test sink must persist before simulating a fresh document");120+ clearDeclaredSink();121+ localStorage.setItem(sinkKey, stored);122+123+ let release!: () => void;124+ const gate = new Promise<void>((resolve) => {125+ release = resolve;126+ });127+ let reads = 0;128+ const delayedStore = {129+ getOrCreate: (keyId: string) => keyStore.getOrCreate(keyId),130+ async get(keyId: string) {131+ reads++;132+ await gate;133+ return await keyStore.get(keyId);134+ },135+ };136+ const boot = ensureDeclaredSinkRestored(delayedStore);137+ const island = (await import("./data-sink.ts?island=early-runtime"))138+ .ensureDeclaredSinkRestored(delayedStore);139+ assert(boot === island, "boot and an eager island must await the same restore promise");140+ assert(141+ readDeclaredSink() === null,142+ "the sink must remain unavailable while restore is pending",143+ );144+ release();145+ assert(await boot === "restored", "the shared restore must open the sealed sink");146+ assert(reads === 1, `the device key was read ${reads} times instead of once`);147+ assert(148+ readDeclaredSink()?.label === "boot race",149+ "the restored sink must precede runtime open",150+ );151+ }),152+);153+154+test(155+ "duplicate app-island module instances share the document sink cache",156+ withCleanState(async () => {157+ const accountIsland = await import("./data-sink.ts?island=account");158+ const deviceIsland = await import("./data-sink.ts?island=device");159+ await accountIsland.declareDataSink({160+ appId: ticketAppId,161+ serverUrl: "https://node.example:4802",162+ label: "shared island sink",163+ });164+ assert(165+ deviceIsland.readDeclaredSink()?.label === "shared island sink",166+ "a sibling island must observe the sink declared through another module instance",167+ );168+ deviceIsland.clearDeclaredSink();169+ assert(170+ accountIsland.readDeclaredSink() === null,171+ "clearing through one island must update every module instance",172+ );173+ }),174+);175+108176 test(109177 "an unreadable or unversioned sink record restores as absent",110178 withCleanState(async () => {
M package/runtime/runtime.ts +8 -3
M tools/demo_overlay_test.ts +4 -0