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 { ensureDeclaredSinkRestored } from "./data-sink.ts";9import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";10import { resolveStoreStatus } from "./store-status.ts";11import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";12import { setEncryptedColumnKey } from "../schema/encrypted.ts";13import {14 ensureDirectoryEntry,15 installSharedFieldIdentityFromSecret,16 type SharedFieldWatchAlert,17 startSharedFieldKeyWatcher,18} from "./shared-field-keys.ts";19import { clearSharedFieldKeys, sharedFieldIdentityOrNull } from "../schema/shared-keyring.ts";20import { sharedColumnConfigs } from "../schema/shared-registry.ts";21import { recordSharedFieldAlert } from "./diagnostics.ts";22import { activeAppId } from "./config.ts";23import { completePopExchange, getOrCreatePopKeyPair } from "./pop.ts";24import { withAuthSecretLock } from "./auth-secret-lock.ts";25import { assertDurableBrowser } from "./device-capabilities.ts";26import {27 createTableStore,28 type TableHandle,29 type TableRow,30 type TableStore,31} from "./table-store.ts";32import {33 AdapterLifecycle,34 createSerializedResourceState,35 getResource,36 recreateResource,37 replaceResource,38 type SerializedResourceState,39 shutdownResource,40} from "./resource-lifecycle.ts";41import {42 createBrokerIncompatibilityHandler,43 reloadLoopFailure,44 runRuntimeStartup,45 RuntimeStartupError,46 type RuntimeStartupFailure,47} from "./startup-recovery.ts";48import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";49import { noteReloadAttempt, resetReloadAttempts } from "./reload-guard.ts";50import {51 adjudicateSyncOwner,52 readSyncOwner,53 recordSyncOwner,54 secretFingerprint,55} from "./sync-owner.ts";56import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";5758/** The shared, lazily opened Jazz client and its application-facing adapters. */59export type LofiRuntime = {60 db: Db;61 diagnostics: RuntimeDiagnostics;62 /**63 * A reactive store bound to one declared table. Repeated calls with the same64 * table return the same store, so every consumer shares one subscription.65 */66 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init>;67 shutdown(): Promise<void>;68};6970/** Event dispatched after account, sync, or runtime replacement changes active state. */71export const runtimeRecreatedEvent = "lofi:runtime-recreated";7273type RuntimeSlot = {74 client: SerializedResourceState<Db>;75 diagnostics: RuntimeDiagnostics;76 diagnosticListeners: Set<() => void>;77 principal: string | null;78 serverConfigured: boolean;79 stopSharedFieldWatcher?: () => void;80};8182const slotName = "__LOFI_ALPHA53_RUNTIME__";83const browserGlobal = globalThis as typeof globalThis & { [slotName]?: RuntimeSlot };8485function slot(): RuntimeSlot {86 browserGlobal[slotName] ??= {87 client: createSerializedResourceState<Db>(),88 diagnostics: createDiagnostics(),89 diagnosticListeners: new Set(),90 principal: null,91 serverConfigured: false,92 };93 return browserGlobal[slotName];94}9596function notifyDiagnostics(state = slot()): void {97 for (const listener of state.diagnosticListeners) listener();98}99100function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure): void {101 const previous = state.diagnostics.startupFailure;102 state.diagnostics.storageState = "failed";103 state.diagnostics.startupFailure = failure;104 bootProgressTracker.mark("failed");105 if (106 previous?.code !== failure.code || previous.runtimeMode !== failure.runtimeMode ||107 previous.message !== failure.message108 ) {109 notifyDiagnostics(state);110 }111}112113// Local-first: a random per-device secret, created on demand and cached — so114// the account opens immediately on first boot, offline, with no ceremony. The115// same secret opens the same account whether or not sync is attached, so a116// later election to back up and sync (see `session.ts`) keeps all existing117// data. Recovering from a phrase replaces the cached secret and recreates the118// runtime, so the restored account's synced data opens under the same identity.119// Creation runs under the app's exclusive secret lock: two first tabs must not120// each mint a secret (see `auth-secret-lock.ts`).121async function resolveAccountSecret(): Promise<string> {122 return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));123}124125// The namespace name doubles as the account's sync-owner fingerprint; both126// must stay byte-identical, so the derivation lives in sync-owner.ts.127async function accountNamespace(secret: string): Promise<string> {128 return await secretFingerprint(secret);129}130131type RuntimeTable = {132 _table: string;133 _schema: Record<string, { columns?: Array<{ name?: string }> }>;134 where(input: Record<string, unknown>): unknown;135};136137function runtimeTables(): RuntimeTable[] {138 const appSchema = getLofiApp().schema;139 // A nested app groups its table handles inside namespace objects, which a140 // one-level walk would silently skip; its flattened registry is141 // authoritative. A flat `defineApp` schema keeps the one-level walk. The142 // author's schema arrives untyped here, so the brand is asserted and the143 // null defense classifies flat apps.144 const values = nestedAppTables(appSchema as NestedAppRoot) ??145 Object.values(appSchema as Record<string, unknown>);146 return values.filter((value) =>147 value && typeof value === "object" && "_table" in value && "where" in value148 ) as RuntimeTable[];149}150151/**152 * Resolves one declared table by name from the active app schema, for153 * package modules that journal writes by table name.154 */155export function findRuntimeTable(156 name: string,157): { where(input: Record<string, unknown>): unknown } | null {158 return runtimeTables().find((table) => table._table === name) ?? null;159}160161async function awaitLocalReady(db: Db): Promise<void> {162 const firstTable = runtimeTables()[0];163 if (!firstTable) return;164 // One row proves the persistent bridge is attached; materializing the whole165 // first table on every open pays row-count-proportional startup cost.166 const query = firstTable.where({}) as { limit?: (n: number) => unknown };167 await db.all((query.limit?.(1) ?? query) as never, { tier: "local" });168}169170/**171 * Whether any declared table holds at least one locally persisted row.172 * Bounded at one row per table, for package modules that need an173 * existence answer without materializing data.174 */175export async function hasAnyLocalRows(): Promise<boolean> {176 const { db } = await getRuntime();177 for (const table of runtimeTables()) {178 const query = table.where({}) as { limit?: (n: number) => unknown };179 const rows = await db.all(180 (query.limit?.(1) ?? query) as never,181 { tier: "local" },182 ) as unknown[];183 if (rows.length > 0) return true;184 }185 return false;186}187188async function migrateLocalRows(189 state: RuntimeSlot,190 db: Db,191 secret: string,192 namespace: string,193): Promise<void> {194 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);195 const local = await runRuntimeStartup(196 "local",197 () =>198 createDb(199 databaseConfig(200 secret,201 namespace,202 "local",203 false,204 createBrokerIncompatibilityHandler("local", record),205 ),206 ),207 record,208 );209 await awaitLocalReady(local);210 for (const table of runtimeTables()) {211 const rows = await local.all(table.where({}) as never, { tier: "local" }) as Array<212 Record<string, unknown> & { id: string }213 >;214 const columns = table._schema[table._table]?.columns ?? [];215 for (const row of rows) {216 const values: Record<string, unknown> = {};217 for (const column of columns) {218 if (column.name && column.name in row) values[column.name] = row[column.name];219 }220 // Local-tier durability is the completion bar: it holds offline, so boot221 // never blocks on the network, and the managed client replicates the222 // copied rows globally through normal sync once connected.223 await db.insert(table as never, values as never, { id: row.id }).wait({ tier: "local" });224 }225 }226 completeLocalRowMigration();227 // Keep the read-only source connection until navigation. In alpha.53,228 // shutting down either browser Db can retire the shared broker used by its229 // sibling; the page lifecycle releases both ports safely.230}231232// Encrypted columns fail closed until this runs: the master key derives from233// the account secret, so it exists before the first database operation and is234// re-derived whenever the runtime is recreated after a secret change.235async function installEncryptedColumnKey(secret: string): Promise<void> {236 const material = await crypto.subtle.importKey(237 "raw",238 new TextEncoder().encode(secret) as BufferSource,239 "HKDF",240 false,241 ["deriveBits"],242 );243 const bits = await crypto.subtle.deriveBits(244 {245 name: "HKDF",246 hash: "SHA-256",247 salt: new Uint8Array(0) as BufferSource,248 info: new TextEncoder().encode("lofi:encrypted-columns:v1") as BufferSource,249 },250 material,251 256,252 );253 setEncryptedColumnKey(new Uint8Array(bits));254}255256async function createClient(state: RuntimeSlot): Promise<Db> {257 const namespaceState = readNamespaceState();258 const connect = syncing();259 const runtimeMode = namespaceState.mode === "managed" || connect ? "managed" : "local";260 const record = (failure: RuntimeStartupFailure) => recordStartupFailure(state, failure);261 state.diagnostics.storageState = "persistent-requested";262 state.diagnostics.startupFailure = null;263 state.diagnostics.storeStatus = { state: "unchecked", reason: "sync-not-connected" };264 state.diagnostics.syncOwner = { state: "unchecked" };265 notifyDiagnostics(state);266 return await runRuntimeStartup(runtimeMode, async () => {267 assertDurableBrowser();268 // On a cold first visit the engine binary is the wait; download it with269 // byte progress before the engine's own fetch reads the primed cache. The270 // warm-up cannot fail boot — it resolves and the engine falls back to271 // resolving its binary itself.272 await bootProgressTracker.warmEngineDownload();273 bootProgressTracker.mark("opening");274 const secret = await resolveAccountSecret();275 await installEncryptedColumnKey(secret);276 // Field keys from a previous principal must never survive into a new277 // client; the watcher re-installs the current account's keys as wraps278 // arrive.279 clearSharedFieldKeys();280 await installSharedFieldIdentityFromSecret(secret);281 const namespace = await accountNamespace(secret);282 // The election is pinned to the account that made it: a different account283 // on the same device (a reset secret store, a partial restore) boots with284 // transport suppressed instead of writing into the owner's store. The285 // namespace name is the fingerprint, so the verdict closes before any286 // client exists.287 let effectiveConnect = connect;288 if (connect) {289 const verdict = adjudicateSyncOwner(namespace);290 if (verdict.state === "foreign") {291 effectiveConnect = false;292 state.diagnostics.syncOwner = { state: "mismatch", owner_user_id: verdict.owner_user_id };293 } else {294 state.diagnostics.syncOwner = { state: "self" };295 }296 notifyDiagnostics(state);297 }298 // A possession-bound sink proves the device key before connecting: the299 // exchange mints a connect token the sync client carries as a path300 // segment. A failed exchange (node restart, revocation, key loss) boots301 // without an override — the connection then fails like a revoked ticket302 // and the existing re-enrollment surface takes over.303 //304 // There is deliberately no re-exchange on reconnect: the client cannot305 // change its server URL after creation, the token's TTL slides on every306 // use, and every runtime recreation passes back through here. The307 // residual — a document idle past the TTL that then reconnects — fails308 // like a revoked ticket and recovers on reload.309 let serverUrlOverride: string | undefined;310 const popSink = activeSink();311 if (effectiveConnect && popSink?.pop) {312 const keyPair = await getOrCreatePopKeyPair(popSink.appId);313 serverUrlOverride = await completePopExchange({314 serverUrl: popSink.serverUrl,315 appId: popSink.appId,316 ticketId: popSink.pop.ticketId,317 keyPair,318 }) ?? undefined;319 }320 // The store preflight rides alongside database creation, never in front321 // of it: local-first boot must not wait on the network. A possession-bound322 // sink must use the authenticated connect URL minted above; its bare323 // ticket URL correctly rejects even metadata reads. resolveStoreStatus324 // maps every remaining failure and timeout to a diagnostic value, so this325 // only records state and never repairs the store.326 const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;327 void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {328 state.diagnostics.storeStatus = status;329 notifyDiagnostics(state);330 });331 const db = await createDb(332 databaseConfig(333 secret,334 namespace,335 runtimeMode,336 effectiveConnect,337 createBrokerIncompatibilityHandler(runtimeMode, record),338 serverUrlOverride,339 ),340 );341 // Browser `createDb()` returns before its persistent worker bridge has342 // necessarily attached. A local-tier query is the public readiness barrier;343 // without it, an immediate post-replacement mutation can see no attainable344 // durability tier and be rejected even though OPFS is configured.345 await awaitLocalReady(db);346 // Gate on the namespace record, not on live sync: rows written before the347 // election must reach the managed namespace even when the connection is348 // unavailable, otherwise they sit hidden in the local namespace.349 if (runtimeMode === "managed" && namespaceState.migrateLocalRows) {350 await migrateLocalRows(state, db, secret, namespace);351 }352 state.principal = db.getAuthState().session?.user_id ?? null;353 state.serverConfigured = effectiveConnect;354 startSharedFieldRuntime(state, db, effectiveConnect);355 // Display-only backfill: the owner record adjudicates by fingerprint, but356 // once the owning account's principal is known it names the owner in357 // mismatch surfaces on other accounts.358 if (effectiveConnect && state.principal !== null) {359 const owner = readSyncOwner();360 if (owner !== null && owner.fingerprint === namespace && owner.user_id === null) {361 recordSyncOwner({ fingerprint: namespace, user_id: state.principal });362 }363 }364 state.diagnostics.storageState = "persistent-driver-open";365 state.diagnostics.startupFailure = null;366 state.diagnostics.clientsCreated += 1;367 state.diagnostics.activeClients += 1;368 // The runtime is open read-write; the compatibility gate may stamp the369 // local schema version forward (it never stamps while data is ahead).370 schemaCompatGate.markRuntimeWritable();371 bootProgressTracker.mark("ready");372 // A settled boot ends any framework reload sequence; the budget restarts373 // from zero for the next one.374 resetReloadAttempts();375 notifyDiagnostics(state);376 return db;377 }, record);378}379380// Shutdown deliberately avoids `logout()`, which would clear local account381// state that must carry forward into a restored or sync-enabled runtime.382// The shared-field runtime: one wrapped-key watcher per client, plus the383// one-time directory self-publication. Both exist only when the schema384// declares shared columns and a sync location is active — local-only apps385// never pay for them.386function startSharedFieldRuntime(state: RuntimeSlot, db: Db, connect: boolean): void {387 const configs = sharedColumnConfigs();388 const userId = db.getAuthState().session?.user_id;389 const identity = sharedFieldIdentityOrNull();390 if (configs.length === 0 || !connect || !userId || !identity) return;391 const alert = (input: SharedFieldWatchAlert) => {392 recordSharedFieldAlert(state.diagnostics, { ...input, at: new Date().toISOString() });393 notifyDiagnostics(state);394 };395 state.stopSharedFieldWatcher = startSharedFieldKeyWatcher({396 db: db as never,397 appId: activeAppId(),398 userId,399 configs,400 findTable: (name) => findRuntimeTable(name),401 onAlert: alert,402 });403 const directoryNames = new Set(configs.map((config) => config.directory));404 for (const directoryName of directoryNames) {405 const directory = findRuntimeTable(directoryName);406 if (!directory) continue;407 void ensureDirectoryEntry({ db: db as never, directory, userId, identity })408 .then((outcome) => {409 if (outcome.state === "self-key-conflict") {410 recordSharedFieldAlert(state.diagnostics, {411 code: "self-key-conflict",412 userId,413 detail: "the published directory key for this account does not match its derived key",414 at: new Date().toISOString(),415 });416 notifyDiagnostics(state);417 }418 }).catch(() => {419 // Publication retries on the next boot; reads stay pending meanwhile.420 });421 }422}423424function stopSharedFieldRuntime(state: RuntimeSlot): void {425 state.stopSharedFieldWatcher?.();426 state.stopSharedFieldWatcher = undefined;427 clearSharedFieldKeys();428}429430async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {431 stopSharedFieldRuntime(state);432 await db.shutdown();433 state.diagnostics.activeClients -= 1;434 notifyDiagnostics(state);435}436437const adapter = new AdapterLifecycle<Db, LofiRuntime>();438let recreationPromise: Promise<LofiRuntime> | null = null;439let shutdownPromise: Promise<void> | null = null;440441function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {442 const stores = new Map<object, TableStore<TableRow, unknown>>();443 let active = true;444 const runtime: LofiRuntime = {445 db,446 diagnostics: state.diagnostics,447 store<T extends TableRow, Init>(table: TableHandle<T, Init>): TableStore<T, Init> {448 let store = stores.get(table);449 if (!store) {450 // Live callback: stores follow later sync elections and stops without451 // being recreated alongside the runtime.452 store = createTableStore(db, table, state.diagnostics, {453 syncConfigured: syncing,454 onDiagnosticsChange: () => notifyDiagnostics(state),455 guardWrite: async () => {456 await assertSchemaWritable();457 return await acquireUpgradeWriteLock();458 },459 }) as TableStore<TableRow, unknown>;460 stores.set(table, store);461 }462 return store as unknown as TableStore<T, Init>;463 },464 shutdown() {465 if (!active) return Promise.resolve();466 active = false;467 for (const store of stores.values()) store.close();468 stores.clear();469 notifyDiagnostics(state);470 return Promise.resolve();471 },472 };473 return runtime;474}475476// The compatibility verdict is part of runtime diagnostics; mirror the shared477// gate into the slot so DeviceStatus and the inspector observe it live.478subscribeSchemaCompat((compat) => {479 const state = slot();480 state.diagnostics.schemaCompat = compat;481 notifyDiagnostics(state);482});483484/** Opens or reuses the one package runtime for the current browser document. */485export async function getRuntime(): Promise<LofiRuntime> {486 // client:load islands can run concurrently with the inline boot module.487 // Never let either path create a client until the one document-wide sink488 // restore has settled, or a configured device can open local-only by race.489 await ensureDeclaredSinkRestored();490 if (recreationPromise) return await recreationPromise;491 const state = slot();492 return await adapter.get(493 () => getResource(state.client, () => createClient(state)),494 (db) => attachRuntime(state, db),495 );496}497498/** Returns a value-only snapshot of runtime resource and durability counters. */499export function getRuntimeDiagnostics(): RuntimeDiagnostics {500 return { ...slot().diagnostics };501}502503/** Applies one package-internal diagnostics update and notifies active observers. */504export function updateRuntimeDiagnostics(505 update: (diagnostics: RuntimeDiagnostics) => void,506): void {507 const state = slot();508 update(state.diagnostics);509 notifyDiagnostics(state);510}511512/** The stable Jazz principal currently opened by the package runtime. */513export function getRuntimePrincipal(): string | null {514 return slot().principal;515}516517/** Whether the active Jazz client was created with a managed transport to reconnect. */518export function runtimeCanReconnect(): boolean {519 return slot().serverConfigured;520}521522/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */523export function subscribeRuntimeDiagnostics(listener: () => void): () => void {524 const listeners = slot().diagnosticListeners;525 listeners.add(listener);526 let active = true;527 return () => {528 if (!active) return;529 active = false;530 listeners.delete(listener);531 };532}533534/** Replaces the active Jazz client while preserving the configured account secret. */535export function recreateRuntime(): Promise<LofiRuntime> {536 if (recreationPromise) return recreationPromise;537 const state = slot();538 const operation = (async () => {539 await adapter.release((runtime) => runtime.shutdown());540 const db = await recreateResource(541 state.client,542 () => createClient(state),543 (current) => destroyClient(state, current),544 );545 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));546 })();547 const tracked = operation.finally(() => {548 if (recreationPromise === tracked) recreationPromise = null;549 });550 recreationPromise = tracked;551 return tracked;552}553554// Refuses a framework-driven reload once the per-tab budget is spent: the555// refusal lands in startup diagnostics (so the recovery surface renders it)556// and throws, so the caller's flow fails loudly instead of cycling.557function assertReloadBudget(): void {558 if (noteReloadAttempt().allowed) return;559 const state = slot();560 const failure = reloadLoopFailure(561 readNamespaceState().mode === "managed" ? "managed" : "local",562 );563 recordStartupFailure(state, failure);564 throw new RuntimeStartupError(failure);565}566567/**568 * Fully tears down a browser persistent worker and reloads the document. Jazz569 * alpha.53 cannot attach a second OPFS worker reliably in the same document570 * after shutdown; navigation is the supported clean-runtime boundary.571 * Framework reloads are budgeted per tab: a sequence of reloads that never572 * reaches a settled boot is refused with a `reload-loop` startup failure573 * instead of cycling.574 */575export async function reloadBrowserRuntime(): Promise<never> {576 assertReloadBudget();577 await shutdownRuntime();578 globalThis.location.reload();579 return await new Promise<never>(() => {});580}581582async function releaseRuntime(state: RuntimeSlot): Promise<void> {583 await adapter.release((runtime) => runtime.shutdown());584 await shutdownResource(state.client, (db) => destroyClient(state, db));585}586587/**588 * Replaces the local-first secret and every runtime object tied to the old589 * principal. Consumers and the old Jazz client close before the secret is590 * replaced; a fresh runtime then opens on the restored principal.591 * `onSecretSaved` runs once the new secret is durably saved and before the592 * replacement runtime opens, so callers can commit dependent state (namespace593 * election) only when replacement is past the point of failure.594 */595export function replaceRuntimePrincipal(596 secret: string,597 options: { onSecretSaved?: () => void | Promise<void> } = {},598): Promise<LofiRuntime> {599 const state = slot();600 const previous = recreationPromise;601 const operation = (async () => {602 // Wait out any in-flight recreation instead of aliasing to it: an603 // unrelated recreation never saves this secret, so returning it would let604 // a restore report success without replacing the principal.605 await previous?.catch(() => undefined);606 const saveSecret = async () => {607 await withAuthSecretLock(appId, () => BrowserAuthSecretStore.saveSecret(secret, { appId }));608 await options.onSecretSaved?.();609 };610 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {611 // The budget check precedes every mutation: a refused reload leaves the612 // current principal and runtime fully intact.613 assertReloadBudget();614 await releaseRuntime(state);615 await saveSecret();616 globalThis.location.reload();617 return await new Promise<LofiRuntime>(() => {});618 }619 await adapter.release((runtime) => runtime.shutdown());620 const db = await replaceResource(621 state.client,622 saveSecret,623 () => createClient(state),624 (current) => destroyClient(state, current),625 );626 return await adapter.get(() => Promise.resolve(db), (current) => attachRuntime(state, current));627 })();628 const tracked = operation.finally(() => {629 if (recreationPromise === tracked) recreationPromise = null;630 });631 recreationPromise = tracked;632 return tracked;633}634635/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */636export function shutdownRuntime(): Promise<void> {637 if (shutdownPromise) return shutdownPromise;638 const state = slot();639 const operation = (async () => {640 await recreationPromise?.catch(() => undefined);641 await releaseRuntime(state);642 })();643 const tracked = operation.finally(() => {644 if (shutdownPromise === tracked) shutdownPromise = null;645 });646 shutdownPromise = tracked;647 return tracked;648}649650import.meta.hot?.dispose(() => adapter.dispose((runtime) => runtime.shutdown()));