clone

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

Merge pull request #83 from FelineStateMachine/fix/namespace-election-recovery

FelineStateMachine committed b288b43
fix(runtime): atomic namespace election, non-destructive recovery
commit
b288b437642e9f292787c1977d77931f2404e730
parent
c87f5795138352d4e9de95e57fbae0018da1b027
parent
ffd751bf77f04a26ecf15219f8a723e951891b0e
author
FelineStateMachine <dami.etoile@gmail.com>
11 files changed, +402 -66
Mapps/reference/src/islands/AccountGate.tsx59+++++++++++-----
Mdocs/auth-identity.md12++-
Mdocs/decisions/passkey-backup-alpha53.md9++-
Apackage/runtime/namespace-state.ts101+++++++++++++++++++++++++++
Apackage/runtime/namespace-state_test.ts112++++++++++++++++++++++++++++++
Mpackage/runtime/resource-lifecycle.ts7+-
Mpackage/runtime/resource-lifecycle_test.ts26+++++++
Mpackage/runtime/runtime.ts57++++++++++-----
Mpackage/runtime/session.ts44+++++++-----
Apackage/runtime/session_test.ts39++++++++++
Mpackage/testdata/starter.snapshot.json2+-
M apps/reference/src/islands/AccountGate.tsx +41 -18
@@ -61,6 +61,10 @@ export default function AccountGate() {
6161 const [error, setError] = useState<string | null>(null);6262 const [phrase, setPhrase] = useState<string | null>(null);6363 const [phraseInput, setPhraseInput] = useState("");64+ // Backup happens in two steps: reveal the phrase first, then enable sync65+ // only after the user confirms it is saved — enabling sync reloads the66+ // document, which would wipe an unread phrase from the screen.67+ const [pendingEnable, setPendingEnable] = useState(false);6468 const [restoring, setRestoring] = useState(false);6569 const [confirmReplacement, setConfirmReplacement] = useState(false);6670 const sharingIdentity = session?.user_id ? encodeSharingIdentity(session.user_id) : null;
@@ -212,24 +216,28 @@ export default function AccountGate() {
212216 portable bearer-secret fallback.213217 </p>214218 <div class="account-actions">215- <button216- type="button"217- disabled={disabled}218- onClick={() =>219- run("enable", async () => {220- try {221- await createRecoverablePasskeyBackup();222- setUnguarded(false);223- } catch (cause) {224- if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") throw cause;225- setUnguarded(true);226- }227- setPhrase(await revealRecoveryPhrase());228- return await enableSyncBackup();229- })}230- >231- {busy === "enable" ? "Backing up…" : "Back up & enable sync"}232- </button>219+ {!pendingEnable && (220+ <button221+ type="button"222+ disabled={disabled}223+ onClick={() =>224+ run("enable", async () => {225+ try {226+ await createRecoverablePasskeyBackup();227+ setUnguarded(false);228+ } catch (cause) {229+ if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") {230+ throw cause;231+ }232+ setUnguarded(true);233+ }234+ setPhrase(await revealRecoveryPhrase());235+ setPendingEnable(true);236+ })}237+ >238+ {busy === "enable" ? "Backing up…" : "Back up & enable sync"}239+ </button>240+ )}233241 <button234242 type="button"235243 class="account-secondary"
@@ -270,6 +278,21 @@ export default function AccountGate() {
270278 lost.271279 </label>272280 {phraseBlock}281+ {pendingEnable && (282+ <div class="account-actions">283+ <p class="account-note">284+ Sync starts once you confirm the phrase is saved — the page reloads and the phrase will285+ not be shown again automatically.286+ </p>287+ <button288+ type="button"289+ disabled={disabled}290+ onClick={() => run("enable", enableSyncBackup)}291+ >292+ {busy === "enable" ? "Enabling sync…" : "I saved my phrase — enable sync"}293+ </button>294+ </div>295+ )}273296 {restoring && (274297 <div class="account-field">275298 <label for="recovery-input">Recovery phrase</label>
M docs/auth-identity.md +8 -4
@@ -37,9 +37,12 @@ local-only: createDb({ appId, secret }) → account X, on this
3737 synced: createDb({ appId, secret, serverUrl }) → account X, now replicated3838 ```3939 40-Electing to sync just recreates the runtime with `serverUrl` added. Because the secret (and thus the41-account id) is unchanged, every write made while local-only is already part of account X and syncs42-up. No migration, no copy, no new account.40+Electing to sync recreates the runtime with `serverUrl` added. Because the secret (and thus the41+account id) is unchanged, every write made while local-only still belongs to account X — no new42+account, and nothing for the user to re-enter. Internally, the election is one durable record: lofi43+opens the account's managed namespace, copies the rows written while local-only into it (waiting44+only for local durability, so the copy also completes offline), and replicates them up through45+normal sync once connected.4346 4447 ## The recovery phrase is the portable backup4548
@@ -77,7 +80,8 @@ confused.
7780 7881 - **First boot** → local-only account opens immediately. No gate.7982 - **Back up & sync** → create a recoverable passkey when supported → reveal the recovery phrase to80- save → turn on sync → the runtime recreates and the account replicates.83+ save → the user confirms the phrase is saved → sync turns on and the runtime recreates (a page84+ reload), and the account replicates. Sync is never enabled while the phrase is still unread.8185 - **Return boots** → the same cached secret opens the same account, synced or not.8286 - **A fresh browser** → explicitly confirm replacement → restore from passkey or phrase → the old8387 subscriptions, stores, workers, cached clients, and Jazz client shut down → the restored account
M docs/decisions/passkey-backup-alpha53.md +6 -3
A package/runtime/namespace-state.ts +101 -0
@@ -0,0 +1,101 @@
1+/**2+ * Package-owned namespace election state.3+ *4+ * One durable record decides which database namespace the runtime opens — the5+ * zero-ceremony local namespace of first boot, or the managed namespace that6+ * can attach sync — and whether rows written locally still need to migrate7+ * across. The record lives in a single localStorage key written atomically,8+ * and this module is its only reader and writer, so session election, account9+ * restore, and runtime startup always observe one consistent state instead of10+ * coordinating separate flags.11+ *12+ * @module13+ */14+15+import { appId } from "./config.ts";16+17+/** Which namespace the runtime opens, and whether local rows still need copying. */18+export type NamespaceState = {19+ /** `local` is the first-boot namespace; `managed` is the one that can attach sync. */20+ mode: "local" | "managed";21+ /** True while rows written in the local namespace still need copying into managed. */22+ migrateLocalRows: boolean;23+};24+25+const stateKey = `lofi:namespace-state:${appId}`;26+// Split flags written by earlier releases; upgraded to the single record on first read.27+const legacyManagedKey = `lofi:managed-runtime:${appId}`;28+const legacyMigrateKey = `lofi:migrate-local-rows:${appId}`;29+30+function firstBoot(): NamespaceState {31+ return { mode: "local", migrateLocalRows: false };32+}33+34+function parseState(raw: string | null): NamespaceState | null {35+ if (!raw) return null;36+ try {37+ const value = JSON.parse(raw) as Partial<NamespaceState> | null;38+ if (value && (value.mode === "local" || value.mode === "managed")) {39+ return { mode: value.mode, migrateLocalRows: value.migrateLocalRows === true };40+ }41+ } catch {42+ // An unreadable record is treated as absent below.43+ }44+ return null;45+}46+47+function writeState(state: NamespaceState): void {48+ try {49+ localStorage.setItem(stateKey, JSON.stringify(state));50+ } catch {51+ // A private-mode storage failure leaves the previous election in place.52+ }53+}54+55+/** Reads the durable namespace election, upgrading legacy split flags in place. */56+export function readNamespaceState(): NamespaceState {57+ if (typeof localStorage === "undefined") return firstBoot();58+ try {59+ const recorded = parseState(localStorage.getItem(stateKey));60+ if (recorded) return recorded;61+ if (localStorage.getItem(legacyManagedKey) === "1") {62+ const upgraded: NamespaceState = {63+ mode: "managed",64+ migrateLocalRows: localStorage.getItem(legacyMigrateKey) === "1",65+ };66+ writeState(upgraded);67+ localStorage.removeItem(legacyManagedKey);68+ localStorage.removeItem(legacyMigrateKey);69+ return upgraded;70+ }71+ return firstBoot();72+ } catch {73+ return firstBoot();74+ }75+}76+77+/**78+ * Elects the managed namespace in one atomic write. A device transitioning79+ * from local mode records whether its local rows must migrate; a device that80+ * is already managed keeps its current migration progress, so re-election can81+ * neither clear a pending migration nor resurrect a completed one.82+ */83+export function electManagedNamespace(options: { migrateLocalRows: boolean }): void {84+ if (typeof localStorage === "undefined") return;85+ const current = readNamespaceState();86+ writeState({87+ mode: "managed",88+ migrateLocalRows: current.mode === "managed"89+ ? current.migrateLocalRows90+ : options.migrateLocalRows,91+ });92+}93+94+/** Records that every local row now exists in the managed namespace. */95+export function completeLocalRowMigration(): void {96+ if (typeof localStorage === "undefined") return;97+ const current = readNamespaceState();98+ if (current.mode === "managed" && current.migrateLocalRows) {99+ writeState({ mode: "managed", migrateLocalRows: false });100+ }101+}
A package/runtime/namespace-state_test.ts +112 -0
@@ -0,0 +1,112 @@
1+// Package contract tests for the durable namespace election record.2+import {3+ completeLocalRowMigration,4+ electManagedNamespace,5+ readNamespaceState,6+} from "./namespace-state.ts";7+import { appId } from "./config.ts";8+import { assert } from "./test-assert.ts";9+10+const test = (globalThis as unknown as {11+ Deno: { test(name: string, body: () => void | Promise<void>): void };12+}).Deno.test;13+14+const stateKey = `lofi:namespace-state:${appId}`;15+const legacyManagedKey = `lofi:managed-runtime:${appId}`;16+const legacyMigrateKey = `lofi:migrate-local-rows:${appId}`;17+const allKeys = [stateKey, legacyManagedKey, legacyMigrateKey];18+19+// Deno persists localStorage across test files and runs, so every test starts20+// from explicitly cleared election keys and leaves none behind.21+function withCleanStorage(body: () => void): () => void {22+ return () => {23+ for (const key of allKeys) localStorage.removeItem(key);24+ try {25+ body();26+ } finally {27+ for (const key of allKeys) localStorage.removeItem(key);28+ }29+ };30+}31+32+test(33+ "first boot elects the local namespace with no pending migration",34+ withCleanStorage(() => {35+ const state = readNamespaceState();36+ assert(state.mode === "local", "a device without an election must boot local");37+ assert(!state.migrateLocalRows, "a local device has nothing to migrate");38+ }),39+);40+41+test(42+ "electing managed records mode and migration in one atomic record",43+ withCleanStorage(() => {44+ electManagedNamespace({ migrateLocalRows: true });45+ const state = readNamespaceState();46+ assert(state.mode === "managed", "election must switch the namespace to managed");47+ assert(state.migrateLocalRows, "a local-to-managed transition must schedule migration");48+ assert(49+ localStorage.getItem(stateKey) !== null && localStorage.getItem(legacyManagedKey) === null,50+ "election must write the single record, never the legacy split flags",51+ );52+ }),53+);54+55+test(56+ "completing migration clears only the pending flag",57+ withCleanStorage(() => {58+ electManagedNamespace({ migrateLocalRows: true });59+ completeLocalRowMigration();60+ const state = readNamespaceState();61+ assert(state.mode === "managed", "completion must not change the elected namespace");62+ assert(!state.migrateLocalRows, "completion must clear the pending migration");63+ }),64+);65+66+test(67+ "re-election preserves migration progress in both directions",68+ withCleanStorage(() => {69+ electManagedNamespace({ migrateLocalRows: true });70+ electManagedNamespace({ migrateLocalRows: false });71+ assert(72+ readNamespaceState().migrateLocalRows,73+ "re-election must never clear a pending migration",74+ );75+ completeLocalRowMigration();76+ electManagedNamespace({ migrateLocalRows: true });77+ assert(78+ !readNamespaceState().migrateLocalRows,79+ "re-election must never resurrect a completed migration",80+ );81+ }),82+);83+84+test(85+ "legacy split flags upgrade to the record and are removed",86+ withCleanStorage(() => {87+ localStorage.setItem(legacyManagedKey, "1");88+ localStorage.setItem(legacyMigrateKey, "1");89+ const state = readNamespaceState();90+ assert(state.mode === "managed", "a legacy managed election must survive the upgrade");91+ assert(state.migrateLocalRows, "a legacy pending migration must survive the upgrade");92+ assert(93+ localStorage.getItem(legacyManagedKey) === null &&94+ localStorage.getItem(legacyMigrateKey) === null,95+ "the upgrade must retire the legacy keys",96+ );97+ assert(98+ localStorage.getItem(stateKey) !== null,99+ "the upgrade must persist the consolidated record",100+ );101+ }),102+);103+104+test(105+ "a corrupted record falls back to the local first-boot state",106+ withCleanStorage(() => {107+ localStorage.setItem(stateKey, "{not json");108+ const state = readNamespaceState();109+ assert(state.mode === "local", "an unreadable record must never elect managed");110+ assert(!state.migrateLocalRows, "an unreadable record must never schedule migration");111+ }),112+);
M package/runtime/resource-lifecycle.ts +4 -3
M package/runtime/resource-lifecycle_test.ts +26 -0
M package/runtime/runtime.ts +39 -18
@@ -25,6 +25,7 @@ import {
2525 runRuntimeStartup,2626 type RuntimeStartupFailure,2727 } from "./startup-recovery.ts";28+import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";2829 2930 /** The shared, lazily opened Jazz client and its application-facing adapters. */3031 export type LofiRuntime = {
@@ -50,8 +51,6 @@ type RuntimeSlot = {
5051 };5152 5253 const slotName = "__LOFI_ALPHA53_RUNTIME__";53-const migrateLocalRowsKey = `lofi:migrate-local-rows:${appId}`;54-const managedRuntimeKey = `lofi:managed-runtime:${appId}`;5554 const browserGlobal = globalThis as typeof globalThis & { [slotName]?: RuntimeSlot };5655 5756 function slot(): RuntimeSlot {
@@ -121,9 +120,6 @@ async function migrateLocalRows(
121120 secret: string,122121 namespace: string,123122 ): Promise<void> {124- if (typeof localStorage === "undefined" || localStorage.getItem(migrateLocalRowsKey) !== "1") {125- return;126- }127123 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);128124 const local = await runRuntimeStartup(129125 "local",
@@ -150,20 +146,22 @@ async function migrateLocalRows(
150146 for (const column of columns) {151147 if (column.name && column.name in row) values[column.name] = row[column.name];152148 }153- await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "global" });149+ // Local-tier durability is the completion bar: it holds offline, so boot150+ // never blocks on the network, and the managed client replicates the151+ // copied rows globally through normal sync once connected.152+ await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "local" });154153 }155154 }156- localStorage.removeItem(migrateLocalRowsKey);155+ completeLocalRowMigration();157156 // Keep the read-only source connection until navigation. In alpha.53,158157 // shutting down either browser Db can retire the shared broker used by its159158 // sibling; the page lifecycle releases both ports safely.160159 }161160 162161 async function createClient(state: RuntimeSlot): Promise<Db> {163- const managedNamespace = typeof localStorage !== "undefined" &&164- localStorage.getItem(managedRuntimeKey) === "1";162+ const namespaceState = readNamespaceState();165163 const connect = syncing();166- const runtimeMode = managedNamespace || connect ? "managed" : "local";164+ const runtimeMode = namespaceState.mode === "managed" || connect ? "managed" : "local";167165 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);168166 state.diagnostics.storageState = "persistent-requested";169167 state.diagnostics.startupFailure = null;
@@ -186,7 +184,12 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
186184 // without it, an immediate post-replacement mutation can see no attainable187185 // durability tier and be rejected even though OPFS is configured.188186 await awaitLocalReady(db);189- if (syncing()) await migrateLocalRows(state, db, secret, namespace);187+ // Gate on the namespace record, not on live sync: rows written before the188+ // election must reach the managed namespace even when the connection is189+ // unavailable, otherwise they sit hidden in the local namespace.190+ if (runtimeMode === "managed" && namespaceState.migrateLocalRows) {191+ await migrateLocalRows(state, db, secret, namespace);192+ }190193 state.principal = db.getAuthState().session?.user_id ?? null;191194 state.serverConfigured = connect;192195 state.diagnostics.storageState = "persistent-driver-open";
@@ -323,25 +326,44 @@ export async function reloadBrowserRuntime(): Promise<never> {
323326 return await new Promise<never>(() => {});324327 }325328 329+async function releaseRuntime(state: RuntimeSlot): Promise<void> {330+ await adapter.release((runtime) => runtime.shutdown());331+ await shutdownResource(state.client, (db) => destroyClient(state, db));332+}333+326334 /**327335 * Replaces the local-first secret and every runtime object tied to the old328336 * principal. Consumers and the old Jazz client close before the secret is329337 * replaced; a fresh runtime then opens on the restored principal.338+ * `onSecretSaved` runs once the new secret is durably saved and before the339+ * replacement runtime opens, so callers can commit dependent state (namespace340+ * election) only when replacement is past the point of failure.330341 */331-export function replaceRuntimePrincipal(secret: string): Promise<LofiRuntime> {332- if (recreationPromise) return recreationPromise;342+export function replaceRuntimePrincipal(343+ secret: string,344+ options: { onSecretSaved?: () => void } = {},345+): Promise<LofiRuntime> {333346 const state = slot();347+ const previous = recreationPromise;334348 const operation = (async () => {335- if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {336- await shutdownRuntime();349+ // Wait out any in-flight recreation instead of aliasing to it: an350+ // unrelated recreation never saves this secret, so returning it would let351+ // a restore report success without replacing the principal.352+ await previous?.catch(() => undefined);353+ const saveSecret = async () => {337354 await BrowserAuthSecretStore.saveSecret(secret, { appId });355+ options.onSecretSaved?.();356+ };357+ if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {358+ await releaseRuntime(state);359+ await saveSecret();338360 globalThis.location.reload();339361 return await new Promise<LofiRuntime>(() => {});340362 }341363 await adapter.release((runtime) => runtime.shutdown());342364 const db = await replaceResource(343365 state.client,344- () => BrowserAuthSecretStore.saveSecret(secret, { appId }),366+ saveSecret,345367 () => createClient(state),346368 (current) => replaceClient(state, current),347369 );
@@ -360,8 +382,7 @@ export function shutdownRuntime(): Promise<void> {
360382 const state = slot();361383 const operation = (async () => {362384 await recreationPromise?.catch(() => undefined);363- await adapter.release((runtime) => runtime.shutdown());364- await shutdownResource(state.client, (db) => destroyClient(state, db));385+ await releaseRuntime(state);365386 })();366387 const tracked = operation.finally(() => {367388 if (shutdownPromise === tracked) shutdownPromise = null;
M package/runtime/session.ts +25 -19
@@ -37,6 +37,7 @@ import {
3737 runtimeCanReconnect,3838 runtimeRecreatedEvent,3939 } from "./runtime.ts";40+import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";4041 4142 /** A snapshot of the account: what is possible and what the user has chosen. */4243 export type Session = {
@@ -63,8 +64,6 @@ export type Session = {
6364 // this only remembers to ask for it before revealing the phrase.6465 const phraseGuardKey = `lofi:phrase-passkey:${appId}`;6566 const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;66-const managedRuntimeKey = `lofi:managed-runtime:${appId}`;67-const migrateLocalRowsKey = `lofi:migrate-local-rows:${appId}`;6867 6968 function phraseGuarded(): boolean {7069 if (typeof localStorage === "undefined") return false;
@@ -207,18 +206,16 @@ export async function revealRecoveryPhrase(): Promise<string> {
207206 * no Jazz app is configured.208207 */209208 export async function enableSyncBackup(): Promise<Session> {210- const canReconnect = typeof localStorage !== "undefined" &&211- localStorage.getItem(managedRuntimeKey) === "1";209+ // The documented no-op: without a managed Jazz app there is nothing to sync210+ // to, and electing the managed namespace anyway would hide the local rows211+ // behind an empty database on every later boot.212+ if (!syncAvailable) return readSession();213+ const alreadyManaged = readNamespaceState().mode === "managed";212214 setSyncElected(true);213- if (canReconnect && runtimeCanReconnect()) {215+ if (alreadyManaged && runtimeCanReconnect()) {214216 await (await getRuntime()).db.reconnect();215217 } else {216- try {217- localStorage.setItem(managedRuntimeKey, "1");218- if (!canReconnect) localStorage.setItem(migrateLocalRowsKey, "1");219- } catch {220- // Runtime mode remains authoritative when local metadata cannot persist.221- }218+ if (!alreadyManaged) electManagedNamespace({ migrateLocalRows: true });222219 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {223220 return await reloadBrowserRuntime();224221 }
@@ -266,16 +263,25 @@ async function replaceAccountSecret(
266263 options: AccountReplacementOptions,267264 ): Promise<Session> {268265 const current = await currentSecret();269- if (current !== secret && !syncElected() && !options.confirmLocalReplacement) {270- throw new AccountReplacementError();266+ if (current === secret) {267+ // The device already holds this account; there is no principal to replace.268+ // Electing backup and sync is the whole remaining intent, and the election269+ // path migrates a never-synced device's local rows instead of hiding them.270+ return await enableSyncBackup();271271 }272- setSyncElected(true);273- try {274- localStorage.setItem(managedRuntimeKey, "1");275- } catch {276- // The restored runtime is still authoritative if metadata cannot persist.272+ if (!syncElected() && !options.confirmLocalReplacement) {273+ throw new AccountReplacementError();277274 }278- await replaceRuntimePrincipal(secret);275+ await replaceRuntimePrincipal(secret, {276+ onSecretSaved: () => {277+ // Commit election only after the restored secret is durably saved: a278+ // replacement that fails earlier leaves the device booting its intact279+ // local account instead of an empty managed namespace.280+ if (!syncAvailable) return;281+ setSyncElected(true);282+ electManagedNamespace({ migrateLocalRows: false });283+ },284+ });279285 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));280286 return readSession();281287 }
A package/runtime/session_test.ts +39 -0
@@ -0,0 +1,39 @@
1+// Package contract tests for account-session election behavior.2+import { enableSyncBackup } from "./session.ts";3+import { readNamespaceState } from "./namespace-state.ts";4+import { appId, syncAvailable } from "./config.ts";5+import { assert } from "./test-assert.ts";6+7+const test = (globalThis as unknown as {8+ Deno: { test(name: string, body: () => void | Promise<void>): void };9+}).Deno.test;10+11+const electionKeys = [12+ `lofi:namespace-state:${appId}`,13+ `lofi:managed-runtime:${appId}`,14+ `lofi:migrate-local-rows:${appId}`,15+ `lofi:sync-elected:${appId}`,16+];17+18+test("enabling sync without a managed Jazz app is the documented no-op", async () => {19+ assert(!syncAvailable, "this contract test requires the unconfigured local build");20+ for (const key of electionKeys) localStorage.removeItem(key);21+ try {22+ const session = await enableSyncBackup();23+ assert(!session.backedUp, "no election may be recorded without a managed Jazz app");24+ assert(!session.syncing, "nothing can replicate without a managed Jazz app");25+ const namespace = readNamespaceState();26+ assert(27+ namespace.mode === "local" && !namespace.migrateLocalRows,28+ "the local namespace must remain elected — a managed election would hide every local row",29+ );30+ for (const key of electionKeys) {31+ assert(32+ localStorage.getItem(key) === null,33+ `the no-op must not persist election state (wrote ${key})`,34+ );35+ }36+ } finally {37+ for (const key of electionKeys) localStorage.removeItem(key);38+ }39+});
M package/testdata/starter.snapshot.json +1 -1