clone

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

feat/prf-provision-custody
lofi / package / runtime / namespace-state.ts
namespace-state.ts 3.6K
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 */1415import { appId } from "./config.ts";1617/** Which namespace the runtime opens, and whether local rows still need copying. */18export 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};2425const stateKey = `lofi:namespace-state:${appId}`;26// Split flags written by earlier releases; upgraded to the single record on first read.27const legacyManagedKey = `lofi:managed-runtime:${appId}`;28const legacyMigrateKey = `lofi:migrate-local-rows:${appId}`;2930function firstBoot(): NamespaceState {31 return { mode: "local", migrateLocalRows: false };32}3334function 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}4647function 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}5455/** Reads the durable namespace election, upgrading legacy split flags in place. */56export 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}7677/**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 */83export 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}9394/** Records that every local row now exists in the managed namespace. */95export 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}