clone

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

fix/175-template-quality-pass
lofi / package / runtime / runtime.ts
runtime.ts 25.1K
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 // The store preflight rides alongside database creation, never in front298 // of it: local-first boot must not wait on the network. resolveStoreStatus299 // maps every failure and timeout to a diagnostic value, so this only300 // records state — a schema-less or drifted store surfaces here at boot301 // instead of as a hanging first write, and is never repaired from here.302 void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {303 state.diagnostics.storeStatus = status;304 notifyDiagnostics(state);305 });306 // A possession-bound sink proves the device key before connecting: the307 // exchange mints a connect token the sync client carries as a path308 // segment. A failed exchange (node restart, revocation, key loss) boots309 // without an override — the connection then fails like a revoked ticket310 // and the existing re-enrollment surface takes over.311 //312 // There is deliberately no re-exchange on reconnect: the client cannot313 // change its server URL after creation, the token's TTL slides on every314 // use, and every runtime recreation passes back through here. The315 // residual — a document idle past the TTL that then reconnects — fails316 // like a revoked ticket and recovers on reload.317 let serverUrlOverride: string | undefined;318 const popSink = activeSink();319 if (effectiveConnect && popSink?.pop) {320 const keyPair = await getOrCreatePopKeyPair(popSink.appId);321 serverUrlOverride = await completePopExchange({322 serverUrl: popSink.serverUrl,323 appId: popSink.appId,324 ticketId: popSink.pop.ticketId,325 keyPair,326 }) ?? undefined;327 }328 const db = await createDb(329 databaseConfig(330 secret,331 namespace,332 runtimeMode,333 effectiveConnect,334 createBrokerIncompatibilityHandler(runtimeMode, record),335 serverUrlOverride,336 ),337 );338 // Browser `createDb()` returns before its persistent worker bridge has339 // necessarily attached. A local-tier query is the public readiness barrier;340 // without it, an immediate post-replacement mutation can see no attainable341 // durability tier and be rejected even though OPFS is configured.342 await awaitLocalReady(db);343 // Gate on the namespace record, not on live sync: rows written before the344 // election must reach the managed namespace even when the connection is345 // unavailable, otherwise they sit hidden in the local namespace.346 if (runtimeMode === "managed" && namespaceState.migrateLocalRows) {347 await migrateLocalRows(state, db, secret, namespace);348 }349 state.principal = db.getAuthState().session?.user_id ?? null;350 state.serverConfigured = effectiveConnect;351 startSharedFieldRuntime(state, db, effectiveConnect);352 // Display-only backfill: the owner record adjudicates by fingerprint, but353 // once the owning account's principal is known it names the owner in354 // mismatch surfaces on other accounts.355 if (effectiveConnect && state.principal !== null) {356 const owner = readSyncOwner();357 if (owner !== null && owner.fingerprint === namespace && owner.user_id === null) {358 recordSyncOwner({ fingerprint: namespace, user_id: state.principal });359 }360 }361 state.diagnostics.storageState = "persistent-driver-open";362 state.diagnostics.startupFailure = null;363 state.diagnostics.clientsCreated += 1;364 state.diagnostics.activeClients += 1;365 // The runtime is open read-write; the compatibility gate may stamp the366 // local schema version forward (it never stamps while data is ahead).367 schemaCompatGate.markRuntimeWritable();368 bootProgressTracker.mark("ready");369 // A settled boot ends any framework reload sequence; the budget restarts370 // from zero for the next one.371 resetReloadAttempts();372 notifyDiagnostics(state);373 return db;374 }, record);375}376377// Shutdown deliberately avoids `logout()`, which would clear local account378// state that must carry forward into a restored or sync-enabled runtime.379// The shared-field runtime: one wrapped-key watcher per client, plus the380// one-time directory self-publication. Both exist only when the schema381// declares shared columns and a sync location is active — local-only apps382// never pay for them.383function startSharedFieldRuntime(state: RuntimeSlot, db: Db, connect: boolean): void {384 const configs = sharedColumnConfigs();385 const userId = db.getAuthState().session?.user_id;386 const identity = sharedFieldIdentityOrNull();387 if (configs.length === 0 || !connect || !userId || !identity) return;388 const alert = (input: SharedFieldWatchAlert) => {389 recordSharedFieldAlert(state.diagnostics, { ...input, at: new Date().toISOString() });390 notifyDiagnostics(state);391 };392 state.stopSharedFieldWatcher = startSharedFieldKeyWatcher({393 db: db as never,394 appId: activeAppId(),395 userId,396 configs,397 findTable: (name) => findRuntimeTable(name),398 onAlert: alert,399 });400 const directoryNames = new Set(configs.map((config) => config.directory));401 for (const directoryName of directoryNames) {402 const directory = findRuntimeTable(directoryName);403 if (!directory) continue;404 void ensureDirectoryEntry({ db: db as never, directory, userId, identity })405 .then((outcome) => {406 if (outcome.state === "self-key-conflict") {407 recordSharedFieldAlert(state.diagnostics, {408 code: "self-key-conflict",409 userId,410 detail: "the published directory key for this account does not match its derived key",411 at: new Date().toISOString(),412 });413 notifyDiagnostics(state);414 }415 }).catch(() => {416 // Publication retries on the next boot; reads stay pending meanwhile.417 });418 }419}420421function stopSharedFieldRuntime(state: RuntimeSlot): void {422 state.stopSharedFieldWatcher?.();423 state.stopSharedFieldWatcher = undefined;424 clearSharedFieldKeys();425}426427async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {428 stopSharedFieldRuntime(state);429 await db.shutdown();430 state.diagnostics.activeClients -= 1;431 notifyDiagnostics(state);432}433434const adapter = new AdapterLifecycle<Db, LofiRuntime>();435let recreationPromise: Promise<LofiRuntime> | null = null;436let shutdownPromise: Promise<void> | null = null;437438function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {439 const stores = new Map<object, TableStore<TableRow, unknown>>();440 let active = true;441 const runtime: LofiRuntime = {442 db,443 diagnostics: state.diagnostics,444 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init> {445 let store = stores.get(table);446 if (!store) {447 // Live callback: stores follow later sync elections and stops without448 // being recreated alongside the runtime.449 store = createTableStore(db, table, state.diagnostics, {450 syncConfigured: syncing,451 onDiagnosticsChange: () => notifyDiagnostics(state),452 guardWrite: async () => {453 await assertSchemaWritable();454 return await acquireUpgradeWriteLock();455 },456 }) as TableStore<TableRow, unknown>;457 stores.set(table, store);458 }459 return store as unknown as TableStore<T, Init>;460 },461 shutdown() {462 if (!active) return Promise.resolve();463 active = false;464 for (const store of stores.values()) store.close();465 stores.clear();466 notifyDiagnostics(state);467 return Promise.resolve();468 },469 };470 return runtime;471}472473// The compatibility verdict is part of runtime diagnostics; mirror the shared474// gate into the slot so DeviceStatus and the inspector observe it live.475subscribeSchemaCompat((compat) => {476 const state = slot();477 state.diagnostics.schemaCompat = compat;478 notifyDiagnostics(state);479});480481/** Opens or reuses the one package runtime for the current browser document. */482export function getRuntime(): Promise<LofiRuntime> {483 if (recreationPromise) return recreationPromise;484 const state = slot();485 return adapter.get(486 () => getResource(state.client, () => createClient(state)),487 (db) => attachRuntime(state, db),488 );489}490491/** Returns a value-only snapshot of runtime resource and durability counters. */492export function getRuntimeDiagnostics(): RuntimeDiagnostics {493 return { ...slot().diagnostics };494}495496/** Applies one package-internal diagnostics update and notifies active observers. */497export function updateRuntimeDiagnostics(498 update: (diagnostics: RuntimeDiagnostics) => void,499): void {500 const state = slot();501 update(state.diagnostics);502 notifyDiagnostics(state);503}504505/** The stable Jazz principal currently opened by the package runtime. */506export function getRuntimePrincipal(): string | null {507 return slot().principal;508}509510/** Whether the active Jazz client was created with a managed transport to reconnect. */511export function runtimeCanReconnect(): boolean {512 return slot().serverConfigured;513}514515/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */516export function subscribeRuntimeDiagnostics(listener: () => void): () => void {517 const listeners = slot().diagnosticListeners;518 listeners.add(listener);519 let active = true;520 return () => {521 if (!active) return;522 active = false;523 listeners.delete(listener);524 };525}526527/** Replaces the active Jazz client while preserving the configured account secret. */528export function recreateRuntime(): Promise<LofiRuntime> {529 if (recreationPromise) return recreationPromise;530 const state = slot();531 const operation = (async () => {532 await adapter.release((runtime) => runtime.shutdown());533 const db = await recreateResource(534 state.client,535 () => createClient(state),536 (current) => destroyClient(state, current),537 );538 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));539 })();540 const tracked = operation.finally(() => {541 if (recreationPromise === tracked) recreationPromise = null;542 });543 recreationPromise = tracked;544 return tracked;545}546547// Refuses a framework-driven reload once the per-tab budget is spent: the548// refusal lands in startup diagnostics (so the recovery surface renders it)549// and throws, so the caller's flow fails loudly instead of cycling.550function assertReloadBudget(): void {551 if (noteReloadAttempt().allowed) return;552 const state = slot();553 const failure = reloadLoopFailure(554 readNamespaceState().mode === "managed" ? "managed" : "local",555 );556 recordStartupFailure(state, failure);557 throw new RuntimeStartupError(failure);558}559560/**561 * Fully tears down a browser persistent worker and reloads the document. Jazz562 * alpha.53 cannot attach a second OPFS worker reliably in the same document563 * after shutdown; navigation is the supported clean-runtime boundary.564 * Framework reloads are budgeted per tab: a sequence of reloads that never565 * reaches a settled boot is refused with a `reload-loop` startup failure566 * instead of cycling.567 */568export async function reloadBrowserRuntime(): Promise<never> {569 assertReloadBudget();570 await shutdownRuntime();571 globalThis.location.reload();572 return await new Promise<never>(() => {});573}574575async function releaseRuntime(state: RuntimeSlot): Promise<void> {576 await adapter.release((runtime) => runtime.shutdown());577 await shutdownResource(state.client, (db) => destroyClient(state, db));578}579580/**581 * Replaces the local-first secret and every runtime object tied to the old582 * principal. Consumers and the old Jazz client close before the secret is583 * replaced; a fresh runtime then opens on the restored principal.584 * `onSecretSaved` runs once the new secret is durably saved and before the585 * replacement runtime opens, so callers can commit dependent state (namespace586 * election) only when replacement is past the point of failure.587 */588export function replaceRuntimePrincipal(589 secret: string,590 options: { onSecretSaved?: () => void | Promise<void> } = {},591): Promise<LofiRuntime> {592 const state = slot();593 const previous = recreationPromise;594 const operation = (async () => {595 // Wait out any in-flight recreation instead of aliasing to it: an596 // unrelated recreation never saves this secret, so returning it would let597 // a restore report success without replacing the principal.598 await previous?.catch(() => undefined);599 const saveSecret = async () => {600 await withAuthSecretLock(appId, () => BrowserAuthSecretStore.saveSecret(secret, { appId }));601 await options.onSecretSaved?.();602 };603 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {604 // The budget check precedes every mutation: a refused reload leaves the605 // current principal and runtime fully intact.606 assertReloadBudget();607 await releaseRuntime(state);608 await saveSecret();609 globalThis.location.reload();610 return await new Promise<LofiRuntime>(() => {});611 }612 await adapter.release((runtime) => runtime.shutdown());613 const db = await replaceResource(614 state.client,615 saveSecret,616 () => createClient(state),617 (current) => destroyClient(state, current),618 );619 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));620 })();621 const tracked = operation.finally(() => {622 if (recreationPromise === tracked) recreationPromise = null;623 });624 recreationPromise = tracked;625 return tracked;626}627628/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */629export function shutdownRuntime(): Promise<void> {630 if (shutdownPromise) return shutdownPromise;631 const state = slot();632 const operation = (async () => {633 await recreationPromise?.catch(() => undefined);634 await releaseRuntime(state);635 })();636 const tracked = operation.finally(() => {637 if (shutdownPromise === tracked) shutdownPromise = null;638 });639 shutdownPromise = tracked;640 return tracked;641}642643import.meta.hot?.dispose(() => adapter.dispose((runtime) => runtime.shutdown()));