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 / runtime.ts
runtime.ts 14.6K
1/// <reference path="./env.d.ts" />2import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";3// Package-owned Jazz runtime.4import { getLofiApp } from "./app.ts";5import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";6import { appId, databaseConfig, syncing } from "./config.ts";7import { assertDurableBrowser } from "./device-capabilities.ts";8import {9 createTableStore,10 type TableHandle,11 type TableRow,12 type TableStore,13} from "./table-store.ts";14import {15 AdapterLifecycle,16 createSerializedResourceState,17 getResource,18 recreateResource,19 replaceResource,20 type SerializedResourceState,21 shutdownResource,22} from "./resource-lifecycle.ts";23import {24 createBrokerIncompatibilityHandler,25 runRuntimeStartup,26 type RuntimeStartupFailure,27} from "./startup-recovery.ts";28import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";29import { nestedAppTables } from "../schema/nested.ts";3031/** The shared, lazily opened Jazz client and its application-facing adapters. */32export type LofiRuntime = {33 db: Db;34 diagnostics: RuntimeDiagnostics;35 /**36 * A reactive store bound to one declared table. Repeated calls with the same37 * table return the same store, so every consumer shares one subscription.38 */39 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init>;40 shutdown(): Promise<void>;41};4243/** Event dispatched after account, sync, or runtime replacement changes active state. */44export const runtimeRecreatedEvent = "lofi:runtime-recreated";4546type RuntimeSlot = {47 client: SerializedResourceState<Db>;48 diagnostics: RuntimeDiagnostics;49 diagnosticListeners: Set<() => void>;50 principal: string | null;51 serverConfigured: boolean;52};5354const slotName = "__LOFI_ALPHA53_RUNTIME__";55const browserGlobal = globalThis as typeof globalThis & { [slotName]?: RuntimeSlot };5657function slot(): RuntimeSlot {58 browserGlobal[slotName] ??= {59 client: createSerializedResourceState<Db>(),60 diagnostics: createDiagnostics(),61 diagnosticListeners: new Set(),62 principal: null,63 serverConfigured: false,64 };65 return browserGlobal[slotName];66}6768function notifyDiagnostics(state = slot()): void {69 for (const listener of state.diagnosticListeners) listener();70}7172function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure): void {73 const previous = state.diagnostics.startupFailure;74 state.diagnostics.storageState = "failed";75 state.diagnostics.startupFailure = failure;76 if (77 previous?.code !== failure.code || previous.runtimeMode !== failure.runtimeMode ||78 previous.message !== failure.message79 ) {80 notifyDiagnostics(state);81 }82}8384// Local-first: a random per-device secret, created on demand and cached — so85// the account opens immediately on first boot, offline, with no ceremony. The86// same secret opens the same account whether or not sync is attached, so a87// later election to back up and sync (see `session.ts`) keeps all existing88// data. Recovering from a phrase replaces the cached secret and recreates the89// runtime, so the restored account's synced data opens under the same identity.90async function resolveAccountSecret(): Promise<string> {91 return await BrowserAuthSecretStore.getOrCreateSecret({ appId });92}9394async function accountNamespace(secret: string): Promise<string> {95 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));96 return [...new Uint8Array(digest).slice(0, 8)]97 .map((byte) => byte.toString(16).padStart(2, "0"))98 .join("");99}100101type RuntimeTable = {102 _table: string;103 _schema: Record<string, { columns?: Array<{ name?: string }> }>;104 where(input: Record<string, unknown>): unknown;105};106107function runtimeTables(): RuntimeTable[] {108 const appSchema = getLofiApp().schema;109 // A nested app groups its table handles inside namespace objects, which a110 // one-level walk would silently skip; its flattened registry is111 // authoritative. A flat `defineApp` schema keeps the one-level walk.112 const values = nestedAppTables(appSchema) ??113 Object.values(appSchema as Record<string, unknown>);114 return values.filter((value) =>115 value && typeof value === "object" && "_table" in value && "where" in value116 ) as RuntimeTable[];117}118119async function awaitLocalReady(db: Db): Promise<void> {120 const firstTable = runtimeTables()[0];121 if (!firstTable) return;122 // One row proves the persistent bridge is attached; materializing the whole123 // first table on every open pays row-count-proportional startup cost.124 const query = firstTable.where({}) as { limit?: (n: number) => unknown };125 await db.all((query.limit?.(1) ?? query) as never, { tier: "local" });126}127128async function migrateLocalRows(129 state: RuntimeSlot,130 db: Db,131 secret: string,132 namespace: string,133): Promise<void> {134 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);135 const local = await runRuntimeStartup(136 "local",137 () =>138 createDb(139 databaseConfig(140 secret,141 namespace,142 "local",143 false,144 createBrokerIncompatibilityHandler("local", record),145 ),146 ),147 record,148 );149 await awaitLocalReady(local);150 for (const table of runtimeTables()) {151 const rows = await local.all(table.where({}) as never, { tier: "local" }) as Array<152 Record<string, unknown> & { id: string }153 >;154 const columns = table._schema[table._table]?.columns ?? [];155 for (const row of rows) {156 const values: Record<string, unknown> = {};157 for (const column of columns) {158 if (column.name && column.name in row) values[column.name] = row[column.name];159 }160 // Local-tier durability is the completion bar: it holds offline, so boot161 // never blocks on the network, and the managed client replicates the162 // copied rows globally through normal sync once connected.163 await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "local" });164 }165 }166 completeLocalRowMigration();167 // Keep the read-only source connection until navigation. In alpha.53,168 // shutting down either browser Db can retire the shared broker used by its169 // sibling; the page lifecycle releases both ports safely.170}171172async function createClient(state: RuntimeSlot): Promise<Db> {173 const namespaceState = readNamespaceState();174 const connect = syncing();175 const runtimeMode = namespaceState.mode === "managed" || connect ? "managed" : "local";176 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);177 state.diagnostics.storageState = "persistent-requested";178 state.diagnostics.startupFailure = null;179 notifyDiagnostics(state);180 return await runRuntimeStartup(runtimeMode, async () => {181 assertDurableBrowser();182 const secret = await resolveAccountSecret();183 const namespace = await accountNamespace(secret);184 const db = await createDb(185 databaseConfig(186 secret,187 namespace,188 runtimeMode,189 connect,190 createBrokerIncompatibilityHandler(runtimeMode, record),191 ),192 );193 // Browser `createDb()` returns before its persistent worker bridge has194 // necessarily attached. A local-tier query is the public readiness barrier;195 // without it, an immediate post-replacement mutation can see no attainable196 // durability tier and be rejected even though OPFS is configured.197 await awaitLocalReady(db);198 // Gate on the namespace record, not on live sync: rows written before the199 // election must reach the managed namespace even when the connection is200 // unavailable, otherwise they sit hidden in the local namespace.201 if (runtimeMode === "managed" && namespaceState.migrateLocalRows) {202 await migrateLocalRows(state, db, secret, namespace);203 }204 state.principal = db.getAuthState().session?.user_id ?? null;205 state.serverConfigured = connect;206 state.diagnostics.storageState = "persistent-driver-open";207 state.diagnostics.startupFailure = null;208 state.diagnostics.clientsCreated += 1;209 state.diagnostics.activeClients += 1;210 notifyDiagnostics(state);211 return db;212 }, record);213}214215// Shutdown deliberately avoids `logout()`, which would clear local account216// state that must carry forward into a restored or sync-enabled runtime.217async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {218 await db.shutdown();219 state.diagnostics.activeClients -= 1;220 notifyDiagnostics(state);221}222223const adapter = new AdapterLifecycle<Db, LofiRuntime>();224let recreationPromise: Promise<LofiRuntime> | null = null;225let shutdownPromise: Promise<void> | null = null;226227function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {228 const stores = new Map<object, TableStore<TableRow, unknown>>();229 let active = true;230 const runtime: LofiRuntime = {231 db,232 diagnostics: state.diagnostics,233 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init> {234 let store = stores.get(table);235 if (!store) {236 // Live callback: stores follow later sync elections and stops without237 // being recreated alongside the runtime.238 store = createTableStore(db, table, state.diagnostics, {239 syncConfigured: syncing,240 onDiagnosticsChange: () => notifyDiagnostics(state),241 }) as TableStore<TableRow, unknown>;242 stores.set(table, store);243 }244 return store as unknown as TableStore<T, Init>;245 },246 shutdown() {247 if (!active) return Promise.resolve();248 active = false;249 for (const store of stores.values()) store.close();250 stores.clear();251 notifyDiagnostics(state);252 return Promise.resolve();253 },254 };255 return runtime;256}257258/** Opens or reuses the one package runtime for the current browser document. */259export function getRuntime(): Promise<LofiRuntime> {260 if (recreationPromise) return recreationPromise;261 const state = slot();262 return adapter.get(263 () => getResource(state.client, () => createClient(state)),264 (db) => attachRuntime(state, db),265 );266}267268/** Returns a value-only snapshot of runtime resource and durability counters. */269export function getRuntimeDiagnostics(): RuntimeDiagnostics {270 return { ...slot().diagnostics };271}272273/** Applies one package-internal diagnostics update and notifies active observers. */274export function updateRuntimeDiagnostics(275 update: (diagnostics: RuntimeDiagnostics) => void,276): void {277 const state = slot();278 update(state.diagnostics);279 notifyDiagnostics(state);280}281282/** The stable Jazz principal currently opened by the package runtime. */283export function getRuntimePrincipal(): string | null {284 return slot().principal;285}286287/** Whether the active Jazz client was created with a managed transport to reconnect. */288export function runtimeCanReconnect(): boolean {289 return slot().serverConfigured;290}291292/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */293export function subscribeRuntimeDiagnostics(listener: () => void): () => void {294 const listeners = slot().diagnosticListeners;295 listeners.add(listener);296 let active = true;297 return () => {298 if (!active) return;299 active = false;300 listeners.delete(listener);301 };302}303304/** Replaces the active Jazz client while preserving the configured account secret. */305export function recreateRuntime(): Promise<LofiRuntime> {306 if (recreationPromise) return recreationPromise;307 const state = slot();308 const operation = (async () => {309 await adapter.release((runtime) => runtime.shutdown());310 const db = await recreateResource(311 state.client,312 () => createClient(state),313 (current) => destroyClient(state, current),314 );315 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));316 })();317 const tracked = operation.finally(() => {318 if (recreationPromise === tracked) recreationPromise = null;319 });320 recreationPromise = tracked;321 return tracked;322}323324/**325 * Fully tears down a browser persistent worker and reloads the document. Jazz326 * alpha.53 cannot attach a second OPFS worker reliably in the same document327 * after shutdown; navigation is the supported clean-runtime boundary.328 */329export async function reloadBrowserRuntime(): Promise<never> {330 await shutdownRuntime();331 globalThis.location.reload();332 return await new Promise<never>(() => {});333}334335async function releaseRuntime(state: RuntimeSlot): Promise<void> {336 await adapter.release((runtime) => runtime.shutdown());337 await shutdownResource(state.client, (db) => destroyClient(state, db));338}339340/**341 * Replaces the local-first secret and every runtime object tied to the old342 * principal. Consumers and the old Jazz client close before the secret is343 * replaced; a fresh runtime then opens on the restored principal.344 * `onSecretSaved` runs once the new secret is durably saved and before the345 * replacement runtime opens, so callers can commit dependent state (namespace346 * election) only when replacement is past the point of failure.347 */348export function replaceRuntimePrincipal(349 secret: string,350 options: { onSecretSaved?: () => void } = {},351): Promise<LofiRuntime> {352 const state = slot();353 const previous = recreationPromise;354 const operation = (async () => {355 // Wait out any in-flight recreation instead of aliasing to it: an356 // unrelated recreation never saves this secret, so returning it would let357 // a restore report success without replacing the principal.358 await previous?.catch(() => undefined);359 const saveSecret = async () => {360 await BrowserAuthSecretStore.saveSecret(secret, { appId });361 options.onSecretSaved?.();362 };363 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {364 await releaseRuntime(state);365 await saveSecret();366 globalThis.location.reload();367 return await new Promise<LofiRuntime>(() => {});368 }369 await adapter.release((runtime) => runtime.shutdown());370 const db = await replaceResource(371 state.client,372 saveSecret,373 () => createClient(state),374 (current) => destroyClient(state, current),375 );376 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));377 })();378 const tracked = operation.finally(() => {379 if (recreationPromise === tracked) recreationPromise = null;380 });381 recreationPromise = tracked;382 return tracked;383}384385/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */386export function shutdownRuntime(): Promise<void> {387 if (shutdownPromise) return shutdownPromise;388 const state = slot();389 const operation = (async () => {390 await recreationPromise?.catch(() => undefined);391 await releaseRuntime(state);392 })();393 const tracked = operation.finally(() => {394 if (shutdownPromise === tracked) shutdownPromise = null;395 });396 shutdownPromise = tracked;397 return tracked;398}399400import.meta.hot?.dispose(() => adapter.dispose((runtime) => runtime.shutdown()));