clone

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

feat/141-demo-review-fixes
lofi / package / runtime / runtime.ts
runtime.ts 25.2K
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 { bootProgressTracker } from "./boot-progress.ts";6import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";7import { activeSink, appId, databaseConfig, syncing } from "./config.ts";8import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";9import { resolveStoreStatus } from "./store-status.ts";10import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";11import { setEncryptedColumnKey } from "../schema/encrypted.ts";12import {13 ensureDirectoryEntry,14 installSharedFieldIdentityFromSecret,15 type SharedFieldWatchAlert,16 startSharedFieldKeyWatcher,17} from "./shared-field-keys.ts";18import { clearSharedFieldKeys, sharedFieldIdentityOrNull } from "../schema/shared-keyring.ts";19import { sharedColumnConfigs } from "../schema/shared-registry.ts";20import { recordSharedFieldAlert } from "./diagnostics.ts";21import { activeAppId } from "./config.ts";22import { completePopExchange, getOrCreatePopKeyPair } from "./pop.ts";23import { withAuthSecretLock } from "./auth-secret-lock.ts";24import { assertDurableBrowser } from "./device-capabilities.ts";25import {26 createTableStore,27 type TableHandle,28 type TableRow,29 type TableStore,30} from "./table-store.ts";31import {32 AdapterLifecycle,33 createSerializedResourceState,34 getResource,35 recreateResource,36 replaceResource,37 type SerializedResourceState,38 shutdownResource,39} from "./resource-lifecycle.ts";40import {41 createBrokerIncompatibilityHandler,42 reloadLoopFailure,43 runRuntimeStartup,44 RuntimeStartupError,45 type RuntimeStartupFailure,46} from "./startup-recovery.ts";47import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";48import { noteReloadAttempt, resetReloadAttempts } from "./reload-guard.ts";49import {50 adjudicateSyncOwner,51 readSyncOwner,52 recordSyncOwner,53 secretFingerprint,54} from "./sync-owner.ts";55import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";5657/** The shared, lazily opened Jazz client and its application-facing adapters. */58export type LofiRuntime = {59 db: Db;60 diagnostics: RuntimeDiagnostics;61 /**62 * A reactive store bound to one declared table. Repeated calls with the same63 * table return the same store, so every consumer shares one subscription.64 */65 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init>;66 shutdown(): Promise<void>;67};6869/** Event dispatched after account, sync, or runtime replacement changes active state. */70export const runtimeRecreatedEvent = "lofi:runtime-recreated";7172type RuntimeSlot = {73 client: SerializedResourceState<Db>;74 diagnostics: RuntimeDiagnostics;75 diagnosticListeners: Set<() => void>;76 principal: string | null;77 serverConfigured: boolean;78 stopSharedFieldWatcher?: () => void;79};8081const slotName = "__LOFI_ALPHA53_RUNTIME__";82const browserGlobal = globalThis as typeof globalThis & { [slotName]?: RuntimeSlot };8384function slot(): RuntimeSlot {85 browserGlobal[slotName] ??= {86 client: createSerializedResourceState<Db>(),87 diagnostics: createDiagnostics(),88 diagnosticListeners: new Set(),89 principal: null,90 serverConfigured: false,91 };92 return browserGlobal[slotName];93}9495function notifyDiagnostics(state = slot()): void {96 for (const listener of state.diagnosticListeners) listener();97}9899function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure): void {100 const previous = state.diagnostics.startupFailure;101 state.diagnostics.storageState = "failed";102 state.diagnostics.startupFailure = failure;103 bootProgressTracker.mark("failed");104 if (105 previous?.code !== failure.code || previous.runtimeMode !== failure.runtimeMode ||106 previous.message !== failure.message107 ) {108 notifyDiagnostics(state);109 }110}111112// Local-first: a random per-device secret, created on demand and cached — so113// the account opens immediately on first boot, offline, with no ceremony. The114// same secret opens the same account whether or not sync is attached, so a115// later election to back up and sync (see `session.ts`) keeps all existing116// data. Recovering from a phrase replaces the cached secret and recreates the117// runtime, so the restored account's synced data opens under the same identity.118// Creation runs under the app's exclusive secret lock: two first tabs must not119// each mint a secret (see `auth-secret-lock.ts`).120async function resolveAccountSecret(): Promise<string> {121 return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));122}123124// The namespace name doubles as the account's sync-owner fingerprint; both125// must stay byte-identical, so the derivation lives in sync-owner.ts.126async function accountNamespace(secret: string): Promise<string> {127 return await secretFingerprint(secret);128}129130type RuntimeTable = {131 _table: string;132 _schema: Record<string, { columns?: Array<{ name?: string }> }>;133 where(input: Record<string, unknown>): unknown;134};135136function runtimeTables(): RuntimeTable[] {137 const appSchema = getLofiApp().schema;138 // A nested app groups its table handles inside namespace objects, which a139 // one-level walk would silently skip; its flattened registry is140 // authoritative. A flat `defineApp` schema keeps the one-level walk. The141 // author's schema arrives untyped here, so the brand is asserted and the142 // null defense classifies flat apps.143 const values = nestedAppTables(appSchema as NestedAppRoot) ??144 Object.values(appSchema as Record<string, unknown>);145 return values.filter((value) =>146 value && typeof value === "object" && "_table" in value && "where" in value147 ) as RuntimeTable[];148}149150/**151 * Resolves one declared table by name from the active app schema, for152 * package modules that journal writes by table name.153 */154export function findRuntimeTable(155 name: string,156): { where(input: Record<string, unknown>): unknown } | null {157 return runtimeTables().find((table) => table._table === name) ?? null;158}159160async function awaitLocalReady(db: Db): Promise<void> {161 const firstTable = runtimeTables()[0];162 if (!firstTable) return;163 // One row proves the persistent bridge is attached; materializing the whole164 // first table on every open pays row-count-proportional startup cost.165 const query = firstTable.where({}) as { limit?: (n: number) => unknown };166 await db.all((query.limit?.(1) ?? query) as never, { tier: "local" });167}168169/**170 * Whether any declared table holds at least one locally persisted row.171 * Bounded at one row per table, for package modules that need an172 * existence answer without materializing data.173 */174export async function hasAnyLocalRows(): Promise<boolean> {175 const { db } = await getRuntime();176 for (const table of runtimeTables()) {177 const query = table.where({}) as { limit?: (n: number) => unknown };178 const rows = await db.all(179 (query.limit?.(1) ?? query) as never,180 { tier: "local" },181 ) as unknown[];182 if (rows.length > 0) return true;183 }184 return false;185}186187async function migrateLocalRows(188 state: RuntimeSlot,189 db: Db,190 secret: string,191 namespace: string,192): Promise<void> {193 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);194 const local = await runRuntimeStartup(195 "local",196 () =>197 createDb(198 databaseConfig(199 secret,200 namespace,201 "local",202 false,203 createBrokerIncompatibilityHandler("local", record),204 ),205 ),206 record,207 );208 await awaitLocalReady(local);209 for (const table of runtimeTables()) {210 const rows = await local.all(table.where({}) as never, { tier: "local" }) as Array<211 Record<string, unknown> & { id: string }212 >;213 const columns = table._schema[table._table]?.columns ?? [];214 for (const row of rows) {215 const values: Record<string, unknown> = {};216 for (const column of columns) {217 if (column.name && column.name in row) values[column.name] = row[column.name];218 }219 // Local-tier durability is the completion bar: it holds offline, so boot220 // never blocks on the network, and the managed client replicates the221 // copied rows globally through normal sync once connected.222 await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "local" });223 }224 }225 completeLocalRowMigration();226 // Keep the read-only source connection until navigation. In alpha.53,227 // shutting down either browser Db can retire the shared broker used by its228 // sibling; the page lifecycle releases both ports safely.229}230231// Encrypted columns fail closed until this runs: the master key derives from232// the account secret, so it exists before the first database operation and is233// re-derived whenever the runtime is recreated after a secret change.234async function installEncryptedColumnKey(secret: string): Promise<void> {235 const material = await crypto.subtle.importKey(236 "raw",237 new TextEncoder().encode(secret) as BufferSource,238 "HKDF",239 false,240 ["deriveBits"],241 );242 const bits = await crypto.subtle.deriveBits(243 {244 name: "HKDF",245 hash: "SHA-256",246 salt: new Uint8Array(0) as BufferSource,247 info: new TextEncoder().encode("lofi:encrypted-columns:v1") as BufferSource,248 },249 material,250 256,251 );252 setEncryptedColumnKey(new Uint8Array(bits));253}254255async function createClient(state: RuntimeSlot): Promise<Db> {256 const namespaceState = readNamespaceState();257 const connect = syncing();258 const runtimeMode = namespaceState.mode === "managed" || connect ? "managed" : "local";259 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);260 state.diagnostics.storageState = "persistent-requested";261 state.diagnostics.startupFailure = null;262 state.diagnostics.storeStatus = { state: "unchecked", reason: "sync-not-connected" };263 state.diagnostics.syncOwner = { state: "unchecked" };264 notifyDiagnostics(state);265 return await runRuntimeStartup(runtimeMode, async () => {266 assertDurableBrowser();267 // On a cold first visit the engine binary is the wait; download it with268 // byte progress before the engine's own fetch reads the primed cache. The269 // warm-up cannot fail boot — it resolves and the engine falls back to270 // resolving its binary itself.271 await bootProgressTracker.warmEngineDownload();272 bootProgressTracker.mark("opening");273 const secret = await resolveAccountSecret();274 await installEncryptedColumnKey(secret);275 // Field keys from a previous principal must never survive into a new276 // client; the watcher re-installs the current account's keys as wraps277 // arrive.278 clearSharedFieldKeys();279 await installSharedFieldIdentityFromSecret(secret);280 const namespace = await accountNamespace(secret);281 // The election is pinned to the account that made it: a different account282 // on the same device (a reset secret store, a partial restore) boots with283 // transport suppressed instead of writing into the owner's store. The284 // namespace name is the fingerprint, so the verdict closes before any285 // client exists.286 let effectiveConnect = connect;287 if (connect) {288 const verdict = adjudicateSyncOwner(namespace);289 if (verdict.state === "foreign") {290 effectiveConnect = false;291 state.diagnostics.syncOwner = { state: "mismatch", owner_user_id: verdict.owner_user_id };292 } else {293 state.diagnostics.syncOwner = { state: "self" };294 }295 notifyDiagnostics(state);296 }297 // A possession-bound sink proves the device key before connecting: the298 // exchange mints a connect token the sync client carries as a path299 // segment. A failed exchange (node restart, revocation, key loss) boots300 // without an override — the connection then fails like a revoked ticket301 // and the existing re-enrollment surface takes over.302 //303 // There is deliberately no re-exchange on reconnect: the client cannot304 // change its server URL after creation, the token's TTL slides on every305 // use, and every runtime recreation passes back through here. The306 // residual — a document idle past the TTL that then reconnects — fails307 // like a revoked ticket and recovers on reload.308 let serverUrlOverride: string | undefined;309 const popSink = activeSink();310 if (effectiveConnect && popSink?.pop) {311 const keyPair = await getOrCreatePopKeyPair(popSink.appId);312 serverUrlOverride = await completePopExchange({313 serverUrl: popSink.serverUrl,314 appId: popSink.appId,315 ticketId: popSink.pop.ticketId,316 keyPair,317 }) ?? undefined;318 }319 // The store preflight rides alongside database creation, never in front320 // of it: local-first boot must not wait on the network. A possession-bound321 // sink must use the authenticated connect URL minted above; its bare322 // ticket URL correctly rejects even metadata reads. resolveStoreStatus323 // maps every remaining failure and timeout to a diagnostic value, so this324 // only records state and never repairs the store.325 const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;326 void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {327 state.diagnostics.storeStatus = status;328 notifyDiagnostics(state);329 });330 const db = await createDb(331 databaseConfig(332 secret,333 namespace,334 runtimeMode,335 effectiveConnect,336 createBrokerIncompatibilityHandler(runtimeMode, record),337 serverUrlOverride,338 ),339 );340 // Browser `createDb()` returns before its persistent worker bridge has341 // necessarily attached. A local-tier query is the public readiness barrier;342 // without it, an immediate post-replacement mutation can see no attainable343 // durability tier and be rejected even though OPFS is configured.344 await awaitLocalReady(db);345 // Gate on the namespace record, not on live sync: rows written before the346 // election must reach the managed namespace even when the connection is347 // unavailable, otherwise they sit hidden in the local namespace.348 if (runtimeMode === "managed" && namespaceState.migrateLocalRows) {349 await migrateLocalRows(state, db, secret, namespace);350 }351 state.principal = db.getAuthState().session?.user_id ?? null;352 state.serverConfigured = effectiveConnect;353 startSharedFieldRuntime(state, db, effectiveConnect);354 // Display-only backfill: the owner record adjudicates by fingerprint, but355 // once the owning account's principal is known it names the owner in356 // mismatch surfaces on other accounts.357 if (effectiveConnect && state.principal !== null) {358 const owner = readSyncOwner();359 if (owner !== null && owner.fingerprint === namespace && owner.user_id === null) {360 recordSyncOwner({ fingerprint: namespace, user_id: state.principal });361 }362 }363 state.diagnostics.storageState = "persistent-driver-open";364 state.diagnostics.startupFailure = null;365 state.diagnostics.clientsCreated += 1;366 state.diagnostics.activeClients += 1;367 // The runtime is open read-write; the compatibility gate may stamp the368 // local schema version forward (it never stamps while data is ahead).369 schemaCompatGate.markRuntimeWritable();370 bootProgressTracker.mark("ready");371 // A settled boot ends any framework reload sequence; the budget restarts372 // from zero for the next one.373 resetReloadAttempts();374 notifyDiagnostics(state);375 return db;376 }, record);377}378379// Shutdown deliberately avoids `logout()`, which would clear local account380// state that must carry forward into a restored or sync-enabled runtime.381// The shared-field runtime: one wrapped-key watcher per client, plus the382// one-time directory self-publication. Both exist only when the schema383// declares shared columns and a sync location is active — local-only apps384// never pay for them.385function startSharedFieldRuntime(state: RuntimeSlot, db: Db, connect: boolean): void {386 const configs = sharedColumnConfigs();387 const userId = db.getAuthState().session?.user_id;388 const identity = sharedFieldIdentityOrNull();389 if (configs.length === 0 || !connect || !userId || !identity) return;390 const alert = (input: SharedFieldWatchAlert) => {391 recordSharedFieldAlert(state.diagnostics, { ...input, at: new Date().toISOString() });392 notifyDiagnostics(state);393 };394 state.stopSharedFieldWatcher = startSharedFieldKeyWatcher({395 db: db as never,396 appId: activeAppId(),397 userId,398 configs,399 findTable: (name) => findRuntimeTable(name),400 onAlert: alert,401 });402 const directoryNames = new Set(configs.map((config) => config.directory));403 for (const directoryName of directoryNames) {404 const directory = findRuntimeTable(directoryName);405 if (!directory) continue;406 void ensureDirectoryEntry({ db: db as never, directory, userId, identity })407 .then((outcome) => {408 if (outcome.state === "self-key-conflict") {409 recordSharedFieldAlert(state.diagnostics, {410 code: "self-key-conflict",411 userId,412 detail: "the published directory key for this account does not match its derived key",413 at: new Date().toISOString(),414 });415 notifyDiagnostics(state);416 }417 }).catch(() => {418 // Publication retries on the next boot; reads stay pending meanwhile.419 });420 }421}422423function stopSharedFieldRuntime(state: RuntimeSlot): void {424 state.stopSharedFieldWatcher?.();425 state.stopSharedFieldWatcher = undefined;426 clearSharedFieldKeys();427}428429async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {430 stopSharedFieldRuntime(state);431 await db.shutdown();432 state.diagnostics.activeClients -= 1;433 notifyDiagnostics(state);434}435436const adapter = new AdapterLifecycle<Db, LofiRuntime>();437let recreationPromise: Promise<LofiRuntime> | null = null;438let shutdownPromise: Promise<void> | null = null;439440function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {441 const stores = new Map<object, TableStore<TableRow, unknown>>();442 let active = true;443 const runtime: LofiRuntime = {444 db,445 diagnostics: state.diagnostics,446 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init> {447 let store = stores.get(table);448 if (!store) {449 // Live callback: stores follow later sync elections and stops without450 // being recreated alongside the runtime.451 store = createTableStore(db, table, state.diagnostics, {452 syncConfigured: syncing,453 onDiagnosticsChange: () => notifyDiagnostics(state),454 guardWrite: async () => {455 await assertSchemaWritable();456 return await acquireUpgradeWriteLock();457 },458 }) as TableStore<TableRow, unknown>;459 stores.set(table, store);460 }461 return store as unknown as TableStore<T, Init>;462 },463 shutdown() {464 if (!active) return Promise.resolve();465 active = false;466 for (const store of stores.values()) store.close();467 stores.clear();468 notifyDiagnostics(state);469 return Promise.resolve();470 },471 };472 return runtime;473}474475// The compatibility verdict is part of runtime diagnostics; mirror the shared476// gate into the slot so DeviceStatus and the inspector observe it live.477subscribeSchemaCompat((compat) => {478 const state = slot();479 state.diagnostics.schemaCompat = compat;480 notifyDiagnostics(state);481});482483/** Opens or reuses the one package runtime for the current browser document. */484export function getRuntime(): Promise<LofiRuntime> {485 if (recreationPromise) return recreationPromise;486 const state = slot();487 return adapter.get(488 () => getResource(state.client, () => createClient(state)),489 (db) => attachRuntime(state, db),490 );491}492493/** Returns a value-only snapshot of runtime resource and durability counters. */494export function getRuntimeDiagnostics(): RuntimeDiagnostics {495 return { ...slot().diagnostics };496}497498/** Applies one package-internal diagnostics update and notifies active observers. */499export function updateRuntimeDiagnostics(500 update: (diagnostics: RuntimeDiagnostics) => void,501): void {502 const state = slot();503 update(state.diagnostics);504 notifyDiagnostics(state);505}506507/** The stable Jazz principal currently opened by the package runtime. */508export function getRuntimePrincipal(): string | null {509 return slot().principal;510}511512/** Whether the active Jazz client was created with a managed transport to reconnect. */513export function runtimeCanReconnect(): boolean {514 return slot().serverConfigured;515}516517/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */518export function subscribeRuntimeDiagnostics(listener: () => void): () => void {519 const listeners = slot().diagnosticListeners;520 listeners.add(listener);521 let active = true;522 return () => {523 if (!active) return;524 active = false;525 listeners.delete(listener);526 };527}528529/** Replaces the active Jazz client while preserving the configured account secret. */530export function recreateRuntime(): Promise<LofiRuntime> {531 if (recreationPromise) return recreationPromise;532 const state = slot();533 const operation = (async () => {534 await adapter.release((runtime) => runtime.shutdown());535 const db = await recreateResource(536 state.client,537 () => createClient(state),538 (current) => destroyClient(state, current),539 );540 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));541 })();542 const tracked = operation.finally(() => {543 if (recreationPromise === tracked) recreationPromise = null;544 });545 recreationPromise = tracked;546 return tracked;547}548549// Refuses a framework-driven reload once the per-tab budget is spent: the550// refusal lands in startup diagnostics (so the recovery surface renders it)551// and throws, so the caller's flow fails loudly instead of cycling.552function assertReloadBudget(): void {553 if (noteReloadAttempt().allowed) return;554 const state = slot();555 const failure = reloadLoopFailure(556 readNamespaceState().mode === "managed" ? "managed" : "local",557 );558 recordStartupFailure(state, failure);559 throw new RuntimeStartupError(failure);560}561562/**563 * Fully tears down a browser persistent worker and reloads the document. Jazz564 * alpha.53 cannot attach a second OPFS worker reliably in the same document565 * after shutdown; navigation is the supported clean-runtime boundary.566 * Framework reloads are budgeted per tab: a sequence of reloads that never567 * reaches a settled boot is refused with a `reload-loop` startup failure568 * instead of cycling.569 */570export async function reloadBrowserRuntime(): Promise<never> {571 assertReloadBudget();572 await shutdownRuntime();573 globalThis.location.reload();574 return await new Promise<never>(() => {});575}576577async function releaseRuntime(state: RuntimeSlot): Promise<void> {578 await adapter.release((runtime) => runtime.shutdown());579 await shutdownResource(state.client, (db) => destroyClient(state, db));580}581582/**583 * Replaces the local-first secret and every runtime object tied to the old584 * principal. Consumers and the old Jazz client close before the secret is585 * replaced; a fresh runtime then opens on the restored principal.586 * `onSecretSaved` runs once the new secret is durably saved and before the587 * replacement runtime opens, so callers can commit dependent state (namespace588 * election) only when replacement is past the point of failure.589 */590export function replaceRuntimePrincipal(591 secret: string,592 options: { onSecretSaved?: () => void | Promise<void> } = {},593): Promise<LofiRuntime> {594 const state = slot();595 const previous = recreationPromise;596 const operation = (async () => {597 // Wait out any in-flight recreation instead of aliasing to it: an598 // unrelated recreation never saves this secret, so returning it would let599 // a restore report success without replacing the principal.600 await previous?.catch(() => undefined);601 const saveSecret = async () => {602 await withAuthSecretLock(appId, () => BrowserAuthSecretStore.saveSecret(secret, { appId }));603 await options.onSecretSaved?.();604 };605 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {606 // The budget check precedes every mutation: a refused reload leaves the607 // current principal and runtime fully intact.608 assertReloadBudget();609 await releaseRuntime(state);610 await saveSecret();611 globalThis.location.reload();612 return await new Promise<LofiRuntime>(() => {});613 }614 await adapter.release((runtime) => runtime.shutdown());615 const db = await replaceResource(616 state.client,617 saveSecret,618 () => createClient(state),619 (current) => destroyClient(state, current),620 );621 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));622 })();623 const tracked = operation.finally(() => {624 if (recreationPromise === tracked) recreationPromise = null;625 });626 recreationPromise = tracked;627 return tracked;628}629630/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */631export function shutdownRuntime(): Promise<void> {632 if (shutdownPromise) return shutdownPromise;633 const state = slot();634 const operation = (async () => {635 await recreationPromise?.catch(() => undefined);636 await releaseRuntime(state);637 })();638 const tracked = operation.finally(() => {639 if (shutdownPromise === tracked) shutdownPromise = null;640 });641 shutdownPromise = tracked;642 return tracked;643}644645import.meta.hot?.dispose(() => adapter.dispose((runtime) => runtime.shutdown()));