Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/demo/overlay/src/islands/IncidentBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default function IncidentBoard() {
autocomplete="off"
/>
<select
name="severity"
aria-label="Severity"
value={severity}
onChange={(event) => setSeverity(event.currentTarget.value as Severity)}
Expand Down
9 changes: 8 additions & 1 deletion package/preact/DeviceStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,14 @@ export function DeviceStatus(): VNode {

useEffect(() => subscribePwaState(setPwa), []);
useEffect(
() => subscribeRuntimeDiagnostics(() => setRuntimeDiagnostics(getRuntimeDiagnostics())),
() =>
subscribeRuntimeDiagnostics(() => {
setRuntimeDiagnostics(getRuntimeDiagnostics());
// Initial boot restores a sealed runtime-declared sink asynchronously.
// An island can mount before that restore completes, so refresh the
// synchronous session snapshot whenever boot diagnostics advance.
setSession(readSession());
}),
[],
);
useEffect(() => {
Expand Down
8 changes: 2 additions & 6 deletions package/runtime/boot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference path="./env.d.ts" />
// Package-owned browser boot orchestration.
import { getLofiApp } from "./app.ts";
import { restoreDeclaredSink } from "./data-sink.ts";
import { ensureDeclaredSinkRestored } from "./data-sink.ts";
import {
attachPwaUpdateCoordination,
registerProductionServiceWorker,
Expand Down Expand Up @@ -69,11 +69,7 @@ export async function bootLofi(): Promise<void> {
// The sink envelope must be open before lifecycle.ts reads the sync
// location at import time. A restore failure degrades to local-only; it
// must never brick boot.
try {
await restoreDeclaredSink();
} catch {
// Continue local-only; enrollment can re-declare the sink.
}
await ensureDeclaredSinkRestored();
startCompatibilityGate();
await import("./lifecycle.ts");
// Arm the write ledger so journaled writes reconcile and outstanding effect
Expand Down
71 changes: 54 additions & 17 deletions package/runtime/data-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,27 @@ const sinkKey = `lofi:data-sink:${anchorAppId}`;
const sinkPurpose = `lofi:data-sink:${anchorAppId}`;
const sinkDeviceKeyId = `data-sink:${anchorAppId}`;

// The declaration in effect for this document, populated by
// restoreDeclaredSink at boot and by successful declarations afterwards.
// Synchronous readers answer from here; storage holds only the envelope.
let cachedSink: DataSinkDeclaration | null = null;
// Astro islands can bundle this module more than once in the same document.
// Keep the restored declaration on globalThis so every island observes the
// boot script's restore and a declaration made by any sibling island. Storage
// still holds only the sealed envelope.
type DataSinkSlot = {
cachedSink: DataSinkDeclaration | null;
lastRestoreOutcome: SinkRestoreOutcome;
restorePromise: Promise<SinkRestoreOutcome> | null;
};

const dataSinkSlotName = "__LOFI_ALPHA53_DATA_SINK__";
const browserGlobal = globalThis as typeof globalThis & { [dataSinkSlotName]?: DataSinkSlot };

function dataSinkSlot(): DataSinkSlot {
browserGlobal[dataSinkSlotName] ??= {
cachedSink: null,
lastRestoreOutcome: "none",
restorePromise: null,
};
return browserGlobal[dataSinkSlotName];
}

function validateDeclaration(value: unknown): DataSinkDeclaration | null {
if (value === null || typeof value !== "object") return null;
Expand Down Expand Up @@ -167,16 +184,14 @@ export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable"
// The most recent restore's outcome, kept so status surfaces can distinguish
// "no sync location" from "a sync location exists but this device cannot open
// it". Cleared when the record is removed or successfully re-declared.
let lastRestoreOutcome: SinkRestoreOutcome = "none";

/**
* How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`
* answer means a declaration is persisted but no available key opens it — the
* device runs local-only until the sink is cleared and re-enrolled, and a
* status surface should say so rather than showing plain local-only.
*/
export function readSinkRestoreOutcome(): SinkRestoreOutcome {
return lastRestoreOutcome;
return dataSinkSlot().lastRestoreOutcome;
}

/**
Expand All @@ -187,12 +202,32 @@ export function readSinkRestoreOutcome(): SinkRestoreOutcome {
export async function restoreDeclaredSink(
keyStore: DeviceKeyStore = defaultDeviceKeyStore(),
): Promise<SinkRestoreOutcome> {
lastRestoreOutcome = await resolveRestore(keyStore);
return lastRestoreOutcome;
const state = dataSinkSlot();
state.lastRestoreOutcome = await resolveRestore(keyStore);
return state.lastRestoreOutcome;
}

/**
* Restores the document's sealed sink exactly once before the first runtime
* opens. App islands and the boot script can start concurrently; sharing this
* promise prevents an eager island from creating a local-only client while
* boot is still unsealing a configured sink.
*/
export function ensureDeclaredSinkRestored(
keyStore: DeviceKeyStore = defaultDeviceKeyStore(),
): Promise<SinkRestoreOutcome> {
const state = dataSinkSlot();
state.restorePromise ??= restoreDeclaredSink(keyStore).catch(() => {
state.cachedSink = null;
state.lastRestoreOutcome = "none";
return "none";
});
return state.restorePromise;
}

async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {
cachedSink = null;
const state = dataSinkSlot();
state.cachedSink = null;
if (typeof localStorage === "undefined") return "none";
let raw: string | null = null;
try {
Expand All @@ -213,7 +248,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
// cleartext bearer URL leaves storage on the first boot that can.
const sink = validateDeclaration(record.sink);
if (!sink) return "none";
cachedSink = sink;
state.cachedSink = sink;
await persistSealed(sink, keyStore);
return "migrated";
}
Expand All @@ -224,7 +259,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));
const sink = validateDeclaration(payload);
if (!sink) return "unopenable";
cachedSink = sink;
state.cachedSink = sink;
return "restored";
} catch (error) {
if (error instanceof EnvelopeError) return "unopenable";
Expand All @@ -240,7 +275,7 @@ async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutc
* (tests, embedders) await {@link restoreDeclaredSink} first.
*/
export function readDeclaredSink(): DataSinkDeclaration | null {
return cachedSink;
return dataSinkSlot().cachedSink;
}

function assertHttpServerUrl(serverUrl: string): void {
Expand Down Expand Up @@ -295,17 +330,19 @@ export async function declareDataSink(
);
}
await persistSealed(declaration, keyStore);
cachedSink = declaration;
const state = dataSinkSlot();
state.cachedSink = declaration;
// The record now seals under an available key; an earlier unopenable
// restore no longer describes it.
lastRestoreOutcome = "restored";
state.lastRestoreOutcome = "restored";
return declaration;
}

/** Removes the declared sink. Existing local data and elections are untouched. */
export function clearDeclaredSink(): void {
cachedSink = null;
lastRestoreOutcome = "none";
const state = dataSinkSlot();
state.cachedSink = null;
state.lastRestoreOutcome = "none";
if (typeof localStorage === "undefined") return;
try {
localStorage.removeItem(sinkKey);
Expand Down
68 changes: 68 additions & 0 deletions package/runtime/data-sink_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
clearDeclaredSink,
declareDataSink,
declareSinkFromTicket,
ensureDeclaredSinkRestored,
isDataSinkError,
parseSyncTicket,
readDeclaredSink,
Expand Down Expand Up @@ -105,6 +106,73 @@ test(
}),
);

test(
"boot and app islands single-flight the sealed sink restore",
withCleanState(async () => {
const keyStore = memoryDeviceKeyStore();
await declareDataSink({
appId: ticketAppId,
serverUrl: "https://node.example:4802",
label: "boot race",
}, keyStore);
const stored = localStorage.getItem(sinkKey);
assert(stored !== null, "the test sink must persist before simulating a fresh document");
clearDeclaredSink();
localStorage.setItem(sinkKey, stored);

let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let reads = 0;
const delayedStore = {
getOrCreate: (keyId: string) => keyStore.getOrCreate(keyId),
async get(keyId: string) {
reads++;
await gate;
return await keyStore.get(keyId);
},
};
const boot = ensureDeclaredSinkRestored(delayedStore);
const island = (await import("./data-sink.ts?island=early-runtime"))
.ensureDeclaredSinkRestored(delayedStore);
assert(boot === island, "boot and an eager island must await the same restore promise");
assert(
readDeclaredSink() === null,
"the sink must remain unavailable while restore is pending",
);
release();
assert(await boot === "restored", "the shared restore must open the sealed sink");
assert(reads === 1, `the device key was read ${reads} times instead of once`);
assert(
readDeclaredSink()?.label === "boot race",
"the restored sink must precede runtime open",
);
}),
);

test(
"duplicate app-island module instances share the document sink cache",
withCleanState(async () => {
const accountIsland = await import("./data-sink.ts?island=account");
const deviceIsland = await import("./data-sink.ts?island=device");
await accountIsland.declareDataSink({
appId: ticketAppId,
serverUrl: "https://node.example:4802",
label: "shared island sink",
});
assert(
deviceIsland.readDeclaredSink()?.label === "shared island sink",
"a sibling island must observe the sink declared through another module instance",
);
deviceIsland.clearDeclaredSink();
assert(
accountIsland.readDeclaredSink() === null,
"clearing through one island must update every module instance",
);
}),
);

test(
"an unreadable or unversioned sink record restores as absent",
withCleanState(async () => {
Expand Down
11 changes: 8 additions & 3 deletions package/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getLofiApp } from "./app.ts";
import { bootProgressTracker } from "./boot-progress.ts";
import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";
import { activeSink, appId, databaseConfig, syncing } from "./config.ts";
import { ensureDeclaredSinkRestored } from "./data-sink.ts";
import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";
import { resolveStoreStatus } from "./store-status.ts";
import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";
Expand Down Expand Up @@ -481,10 +482,14 @@ subscribeSchemaCompat((compat) => {
});

/** Opens or reuses the one package runtime for the current browser document. */
export function getRuntime(): Promise<LofiRuntime> {
if (recreationPromise) return recreationPromise;
export async function getRuntime(): Promise<LofiRuntime> {
// client:load islands can run concurrently with the inline boot module.
// Never let either path create a client until the one document-wide sink
// restore has settled, or a configured device can open local-only by race.
await ensureDeclaredSinkRestored();
if (recreationPromise) return await recreationPromise;
const state = slot();
return adapter.get(
return await adapter.get(
() => getResource(state.client, () => createClient(state)),
(db) => attachRuntime(state, db),
);
Expand Down
4 changes: 4 additions & 0 deletions tools/demo_overlay_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ Deno.test("the demo uses the starter's durable notice surface", async () => {
"the overlay must not restore the hand-rolled in-memory notice channel",
);
assert(board.includes("<Notices"), "the incident board must render the durable notice queue");
assert(
board.includes('name="severity"'),
"the severity control must expose a form-field name for browser tooling",
);
});
Loading