- commit
- 9cb2708abc83d09fba87b267380348f97c912d19
- parent
- 1a7e848ee93aa5b88b78b261247a4689265e9f56
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 1 | + |
| M | package/ | 9 | +++- |
| M | package/ | 8 | +--- |
| M | package/ | 71 | +++++++++++++++++++++++------- |
| M | package/ | 68 | +++++++++++++++++++++++++++++ |
| M | package/ | 11 | +++- |
| M | tools/ | 4 | ++ |
@@ -78,6 +78,7 @@ export default function IncidentBoard() {
7878 autocomplete="off"7979 />8080 <select81+ name="severity"8182 aria-label="Severity"8283 value={severity}8384 onChange={(event) => setSeverity(event.currentTarget.value as Severity)}@@ -98,7 +98,14 @@ export function DeviceStatus(): VNode {
9898 9999 useEffect(() => subscribePwaState(setPwa), []);100100 useEffect(101- () => subscribeRuntimeDiagnostics(() => setRuntimeDiagnostics(getRuntimeDiagnostics())),101+ () =>102+ subscribeRuntimeDiagnostics(() => {103+ setRuntimeDiagnostics(getRuntimeDiagnostics());104+ // Initial boot restores a sealed runtime-declared sink asynchronously.105+ // An island can mount before that restore completes, so refresh the106+ // synchronous session snapshot whenever boot diagnostics advance.107+ setSession(readSession());108+ }),102109 [],103110 );104111 useEffect(() => {@@ -1,7 +1,7 @@
11 /// <reference path="./env.d.ts" />22 // Package-owned browser boot orchestration.33 import { getLofiApp } from "./app.ts";4-import { restoreDeclaredSink } from "./data-sink.ts";4+import { ensureDeclaredSinkRestored } from "./data-sink.ts";55 import {66 attachPwaUpdateCoordination,77 registerProductionServiceWorker,@@ -69,11 +69,7 @@ export async function bootLofi(): Promise<void> {
6969 // The sink envelope must be open before lifecycle.ts reads the sync7070 // location at import time. A restore failure degrades to local-only; it7171 // must never brick boot.72- try {73- await restoreDeclaredSink();74- } catch {75- // Continue local-only; enrollment can re-declare the sink.76- }72+ await ensureDeclaredSinkRestored();7773 startCompatibilityGate();7874 await import("./lifecycle.ts");7975 // Arm the write ledger so journaled writes reconcile and outstanding effect@@ -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 { 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);@@ -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 () => {@@ -5,6 +5,7 @@ import { getLofiApp } from "./app.ts";
55 import { bootProgressTracker } from "./boot-progress.ts";66 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";77 import { activeSink, appId, databaseConfig, syncing } from "./config.ts";8+import { ensureDeclaredSinkRestored } from "./data-sink.ts";89 import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";910 import { resolveStoreStatus } from "./store-status.ts";1011 import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";@@ -481,10 +482,14 @@ subscribeSchemaCompat((compat) => {
481482 });482483 483484 /** Opens or reuses the one package runtime for the current browser document. */484-export function getRuntime(): Promise<LofiRuntime> {485- if (recreationPromise) return recreationPromise;485+export async function getRuntime(): Promise<LofiRuntime> {486+ // client:load islands can run concurrently with the inline boot module.487+ // Never let either path create a client until the one document-wide sink488+ // restore has settled, or a configured device can open local-only by race.489+ await ensureDeclaredSinkRestored();490+ if (recreationPromise) return await recreationPromise;486491 const state = slot();487- return adapter.get(492+ return await adapter.get(488493 () => getResource(state.client, () => createClient(state)),489494 (db) => attachRuntime(state, db),490495 );@@ -48,4 +48,8 @@ Deno.test("the demo uses the starter's durable notice surface", async () => {
4848 "the overlay must not restore the hand-rolled in-memory notice channel",4949 );5050 assert(board.includes("<Notices"), "the incident board must render the durable notice queue");51+ assert(52+ board.includes('name="severity"'),53+ "the severity control must expose a form-field name for browser tooling",54+ );5155 });