API index and search · Build metadata
Source snapshot
packages/optimistic/src/local-persist.ts
1// Local-table persistence (207-LOCAL-TABLE-PERSISTENCE-DESIGN.md): durable, cross-tab2// local-only tables via IndexedDB + a Web-Locks leader.3//4// The whole layer lives OUTSIDE the backend, over exactly two seams 201/207 carved:5// - `backend.onLocalWrite(observer)` — every committed `writeLocal` batch (the write-through tap);6// - `backend.applyLocalReplica(muts)` — the application path for restore + commits (no observer,7// so the echo guard is structural; still funnels the engine's M2 locality guard, P8).8//9// Shape (§4): every tab requests one exclusive Web Lock — the holder is the leader, the browser's10// queue is the whole election (§4.1). The leader is the sole IDB writer and the commit sequencer;11// it persists a batch, THEN broadcasts a `commit` (P1/P2). Followers apply their own writes12// immediately, forward row-state ops to the leader, and hold them unacked until the matching13// commit (P4). All replication is idempotent full-ROW STATE (`put(pk,row)`/`tombstone(pk)`)14// applied through a per-tab MIRROR (a JS Map per local table) that diffs incoming state against15// known state and emits the correct engine add/edit/remove (§4.4, P3) — resend, replay, and16// snapshot/stream overlap are all no-ops by construction.17//18// Browser globals are reached via structural typing (this package has no DOM lib — precedent19// `client.ts:154-158`); the same structural seam (`PersistEnv`) is what the tests inject a fake20// IDB/channel/locks environment through.2122import { localSchemaHash, persistedLocalTableNames, rowsEqual, tableSpec } from "@rindle/client";23import type { ColsMap, Mutation, Schema, WireValue } from "@rindle/client";2425import type { OptimisticBackend } from "./backend.ts";2627// ---------------------------------------------------------------------------------------------28// Public option/handle surface (§5.2)29// ---------------------------------------------------------------------------------------------3031export interface PersistLocalOptions {32 /** The storage identity (§3.2): one IDB database per (origin, user). NOT the mutator principal —33 * this is fixed for the client's lifetime; an anonymous mode passes its own sentinel (`"anon"`). */34 user: string;35 /** Call `navigator.storage.persist()` to resist eviction (§3.2). Default false — it can prompt. */36 requestPersistentStorage?: boolean;37 /** Reports storage-operation failures without rejecting local writes. Default `console.error`.38 * Missing browser APIs can warn and disable a feature instead; this hook does not receive39 * every channel failure. Persistence and cross-tab delivery remain best effort. */40 onError?: (e: Error) => void;41 /** Test seam: a fake IDB/locks/channel environment. Defaults to the browser globals. */42 env?: PersistEnv;43}4445/** The attached layer's handle. `createRindleClient` awaits {@link ready} before returning (§5.2). */46export interface LocalPersistence {47 /** Resolves after the initial restore attempt. It can resolve in a degraded mode when browser48 * storage is unavailable or an operation fails; it is not a durability guarantee. */49 readonly ready: Promise<void>;50 /** Best-effort final persist/forward (the `pagehide` hook, §9): re-posts unacked ops (any51 * role), then a leader drains its persist queue and retries P9-degraded batches. */52 flush(): Promise<void>;53 /** Release leadership (or abandon the queued lock request), close the channel + IDB (P10).54 * A leader DRAINS its queued persist steps first (they carry already-committed writes) and55 * releases the lock only after the last one lands; a follower re-posts its unacked ops. */56 close(): void;57 /** Introspection for tests/devtools: current role. */58 role(): "leader" | "follower";59}6061/** Delete a user's local-persistence database — the sanctioned LOGOUT hook (§3.2). Never called62 * implicitly; the old database otherwise remains on disk (fast re-login, and a privacy decision63 * the app owns). Close this tab's live client for `user` first; a SIBLING tab's connection is64 * released automatically (it closes on `versionchange` and degrades to broadcast-only), so a65 * multi-tab logout completes instead of parking behind the other tab forever. */66export function deleteLocalPersistence(user: string, env?: PersistEnv): Promise<void> {67 return (env ?? defaultEnv()).deleteDatabase(dbName(user));68}6970/** Attach the persistence layer to a backend (standalone form — `createRindleClient` calls this71 * for you). The observer is wired SYNCHRONOUSLY, but the mirror starts empty: attach before the72 * first `writeLocal` (§5.2, a v1 constraint `createRindleClient` satisfies by construction).73 * Await `handle.ready` before first render if the app wants restored rows at first paint. */74export function attachLocalPersistence<S extends ColsMap>(75 backend: OptimisticBackend<S>,76 schema: Schema<S>,77 opts: PersistLocalOptions,78): LocalPersistence {79 return new LocalPersistLayer(backend, schema, opts);80}8182// ---------------------------------------------------------------------------------------------83// The environment seam — the browser surface, structurally typed (real by default, faked in tests)84// ---------------------------------------------------------------------------------------------8586/** The narrow storage surface the layer needs (§3.1): two fixed stores, `rows` + `meta`. Each87 * method is one atomic IDB transaction; a resolved promise means the txn COMPLETED — the P188 * ("persist-then-broadcast") anchor. */89export interface PersistDb {90 getMeta(): Promise<PersistMeta | undefined>;91 putMeta(meta: PersistMeta): Promise<void>;92 getAllRows(): Promise<StoredRow[]>;93 /** Apply one commit batch atomically: `row === null` deletes, else puts. */94 putBatch(batch: RowState[]): Promise<void>;95 /** The P7 gate: clear `rows` and write `meta` in ONE transaction (never a half state). */96 reset(meta: PersistMeta): Promise<void>;97 /** Delete specific records (the leader's stale-table sweep, §3.1). */98 deleteRows(keys: Array<{ table: string; pkKey: string }>): Promise<void>;99 close(): void;100}101102export interface PersistChannel {103 post(msg: unknown): void;104 onMessage(handler: (msg: unknown) => void): void;105 close(): void;106}107108export interface PersistEnv {109 /** Open (creating if needed) the database. Resolves `null` when storage is unavailable —110 * the layer degrades to broadcast-only (§3.2). */111 openDatabase(name: string): Promise<PersistDb | null>;112 deleteDatabase(name: string): Promise<void>;113 /** Open the tab-coherence channel; `null` when BroadcastChannel is unavailable (single-tab). */114 createChannel(name: string): PersistChannel | null;115 /** Queue for the exclusive leadership lock (§4.1): `onAcquired`'s promise HOLDS the lock until116 * it resolves; `signal` aborts a still-queued request. When Web Locks are unavailable, grant117 * immediately ONLY if the runtime is provably single-context (no channel) — see118 * {@link leaderElectionUnavailable}. */119 requestLock(name: string, signal: AbortSignal, onAcquired: () => Promise<void>): void;120 /** The runtime has tabs (a channel) but NO exclusive lock (Firefox <96, Safari 15.1–15.3,121 * Node): every context would self-promote into concurrent leaders over one database — P2122 * violated, permanent divergence. When set, the layer runs INERT: local tables still work,123 * session-scoped (the 201 baseline), with no persistence and no cross-tab replication. */124 leaderElectionUnavailable?: boolean;125 /** `navigator.storage.persist()` (§3.2), best-effort. */126 requestPersistentStorage?(): void;127}128129export interface PersistMeta {130 schemaHash: string;131 epoch: number;132}133134export interface StoredRow {135 table: string;136 pkKey: string;137 row: WireValue[];138}139140/** The replication unit everywhere (§4.3): idempotent full-row state. `row: null` = tombstone141 * (live protocol only — removes DELETE the IDB record; there are no persisted tombstones). */142export interface RowState {143 table: string;144 pkKey: string;145 row: WireValue[] | null;146}147148// The three messages (§4.3), one BroadcastChannel per (origin, user, localSchemaHash). `p` on a149// commit = "this batch is durably in IDB" (false ⇒ the P9 degrade fired): every tab tracks the150// un-persisted keys so a later promotion REPAIRS them from the mirror instead of misreading the151// IDB gap as a remove.152type PersistMsg =153 | { t: "op"; origin: string; seq: number; batch: RowState[] }154 | { t: "commit"; epoch: number; lseq: number; origin: string; seq: number; batch: RowState[]; p: boolean }155 | { t: "leader"; epoch: number };156157function dbName(user: string): string {158 return `rindle-local:${user}`;159}160161/** The channel is partitioned by schema hash (the lock + database stay per-user): a mixed-version162 * peer mid-deploy must never replicate old-shape rows into a new-schema engine — the width guard163 * cannot catch a same-width reshape (a column rename or retype), so cross-version traffic is made164 * structurally impossible instead. The old-version leader keeps the shared lock until it closes;165 * new-version tabs run local-first meanwhile and adopt their unacked ops at promotion (§4.5). */166function channelName(user: string, schemaHash: string): string {167 return `${dbName(user)}::${schemaHash}`;168}169170// ---------------------------------------------------------------------------------------------171// The layer172// ---------------------------------------------------------------------------------------------173174interface LocalTableInfo {175 /** pk column indices, in schema pk order. */176 pk: number[];177 /** Full positional row width — the shape guard for replicated rows (a wrong-width row must not178 * reach the wasm engine, which builds `panic = "abort"`). */179 width: number;180}181182class LocalPersistLayer<S extends ColsMap> implements LocalPersistence {183 readonly ready: Promise<void>;184185 private readonly backend: OptimisticBackend<S>;186 private readonly onError: (e: Error) => void;187 private readonly env: PersistEnv;188 /** Per PERSISTED local table (`local: true` — a `local: "session"` table stays outside the189 * plane, §5.4): pk extraction + width (from the same schema meta `tableSpec` reads, §5.3). */190 private readonly tables = new Map<string, LocalTableInfo>();191 private readonly schemaHash: string;192193 /** The §4.4 mirror: per PERSISTED local table, pkKey → engine row — the JS twin of the local194 * source. A `null` value is a PRE-RESTORE TOMBSTONE (§4.6 step 4): a remove issued before the195 * snapshot landed must not be resurrected by it (`has()` covers both shapes); swept once live. */196 private readonly mirror = new Map<string, Map<string, WireValue[] | null>>();197 /** `local: "session"` table names (§5.4) — inbound states naming one are dropped, never198 * funneled to the engine (M2 admits local tables, so the isolation guard lives here). */199 private readonly sessionOnly = new Set<string>();200201 /** This layer instance's identity on the channel (NOT the clientID — two clients in one tab are202 * two "tabs" here, §9). */203 private readonly origin: string;204 private channel: PersistChannel | null = null;205 private db: PersistDb | null = null;206207 // --- role / election state (§4.1–§4.2) ---208 private leader = false;209 /** Leader-only: announcement broadcast; incoming `op`s are processed. Before this (mid-promotion)210 * ops are dropped — the sender re-sends on the `leader` announcement (§4.5). */211 private leaderLive = false;212 private releaseLock: (() => void) | null = null;213 private readonly lockAbort = new AbortController();214 private epoch = 0;215 /** Highest `leader`/`commit` epoch seen; commits below it are discarded (P6). */216 private highestEpoch = 0;217 private lseq = 0;218 /** Leader-only: the serialized persist→broadcast chain (P1/P2 — one writer, one order). */219 private persistChain: Promise<void> = Promise.resolve();220221 // --- follower state (P4) ---222 private opSeq = 0;223 /** Ops applied locally + forwarded, held until the matching `commit` acks them (§4.5). */224 private readonly unacked = new Map<number, RowState[]>();225 /** Per-pk hold-back (§4.2): `table\0pkKey` → the seq of this tab's LATEST uncommitted write to226 * that pk. While set, any other state for the pk (our own earlier echo, or a foreign write the227 * leader sequenced before ours) is stale relative to our tail — applying it would visibly228 * rewind the row and then snap it forward again. Cleared when the write's own commit applies. */229 private readonly pendingByPk = new Map<string, number>();230 /** P9 bookkeeping: `table\0pkKey`s whose LAST commit was broadcast with `p: false` (the persist231 * failed or there is no IDB) — IDB does not reflect the coherent state for them. A leader232 * retries them from the mirror on later chain steps; a promotion REPAIRS them before diffing,233 * so the IDB gap is never misread as a persisted-but-unannounced remove. */234 private readonly notInIdb = new Set<string>();235236 // --- boot state (§4.6) ---237 private live = false;238 private readonly bootBuffer: PersistMsg[] = [];239 /** Boot's meta verdict — the FALLBACK for the P7 decision when promotion's authoritative240 * re-read (under the lock) fails: anything but a clean boot `match` then fails CLOSED241 * (reset), because stamping the new hash over unverified rows would legitimize old-shape242 * data forever. `error` = boot couldn't read meta (nothing was restored). */243 private bootMeta: "match" | "mismatch" | "error" = "error";244 /** Resolves once the §4.6 restore has run. A field initializer (not a `start()` return) so it245 * exists BEFORE the lock request — an immediately-granted lock (solo mode / a free queue) must246 * still park its promotion behind the restore. */247 private restoreDone!: () => void;248 private readonly restored = new Promise<void>((r) => {249 this.restoreDone = r;250 });251 private closed = false;252 /** {@link PersistEnv.leaderElectionUnavailable}: the layer is attached but INERT. */253 private readonly disabled: boolean;254255 constructor(backend: OptimisticBackend<S>, schema: Schema<S>, opts: PersistLocalOptions) {256 this.backend = backend;257 this.env = opts.env ?? defaultEnv();258 this.onError = opts.onError ?? ((e) => console.error("[rindle] local persistence:", e));259 this.schemaHash = localSchemaHash(schema);260 this.origin = mintOrigin();261 for (const name of persistedLocalTableNames(schema)) {262 const spec = tableSpec(schema.tables[name]);263 this.tables.set(name, { pk: spec.primaryKey, width: spec.columns.length });264 this.mirror.set(name, new Map());265 }266 // `local: "session"` tables (§5.4): local for every 201 rule, but OUTSIDE this plane — their267 // writes are never forwarded/persisted, and an inbound state naming one (a mixed-version268 // peer) must be dropped HERE: M2 would happily admit it (it IS local), which would break the269 // per-tab isolation being ephemeral promises.270 for (const name of Object.keys(schema.tables)) {271 if (schema.tables[name].local === "session") this.sessionOnly.add(name);272 }273274 // The write-through tap (§5.1): committed origin writes → row state → mirror, then routed by275 // role. Wired synchronously so no write can slip past the layer (the mirror starts empty).276 backend.onLocalWrite((muts) => this.onOriginWrite(muts));277278 if (this.env.leaderElectionUnavailable) {279 // Tabs without an exclusive lock would all self-promote: concurrent leaders over one280 // database, P2 violated, permanent divergence. Run inert instead — local tables keep281 // working, session-scoped (the 201 baseline).282 this.disabled = true;283 console.warn(284 "[rindle] Web Locks unavailable — local-table persistence disabled (local tables are session-scoped).",285 );286 this.ready = Promise.resolve();287 this.restoreDone();288 return;289 }290 this.disabled = false;291292 if (opts.requestPersistentStorage) this.env.requestPersistentStorage?.();293294 // §4.6 attach order: (1) channel first — start buffering; (2) queue for the lock; (3–5) open295 // IDB, snapshot, replay (in start()).296 this.channel = this.env.createChannel(channelName(opts.user, this.schemaHash));297 this.channel?.onMessage((msg) => this.onMessage(msg as PersistMsg));298 this.env.requestLock(`rindle-local-leader:${opts.user}`, this.lockAbort.signal, () => this.onLockAcquired());299300 this.ready = this.start(opts.user);301 void this.ready.then(this.restoreDone);302 }303304 role(): "leader" | "follower" {305 return this.leader ? "leader" : "follower";306 }307308 // -------------------------------------------------------------------------------------------309 // Boot: subscribe → snapshot → replay (§4.6)310 // -------------------------------------------------------------------------------------------311312 private async start(user: string): Promise<void> {313 try {314 this.db = await this.env.openDatabase(dbName(user));315 } catch (e) {316 this.db = null;317 this.onError(asError(e));318 }319 if (this.closed) {320 // close() raced the open (it ran with db still null): release the fresh handle here or it321 // holds the database open forever, blocking every later deleteLocalPersistence.322 this.db?.close();323 this.db = null;324 return;325 }326 if (!this.db) {327 console.warn("[rindle] IndexedDB unavailable — local tables are session-scoped (broadcast-only).");328 }329330 let snapshot: StoredRow[] = [];331 if (this.db) {332 try {333 const meta = await this.db.getMeta();334 if (meta?.schemaHash === this.schemaHash) {335 this.bootMeta = "match";336 this.epoch = meta.epoch;337 this.highestEpoch = Math.max(this.highestEpoch, meta.epoch);338 snapshot = await this.db.getAllRows();339 } else {340 // P7: mismatch (or missing meta) ⇒ start empty. The on-disk CLEAR is deferred to341 // promotion (only the lock holder writes IDB — P2); until then we simply load nothing.342 this.bootMeta = "mismatch";343 this.epoch = meta?.epoch ?? 0;344 }345 } catch (e) {346 this.bootMeta = "error"; // nothing restored; promotion must fail CLOSED on this store347 this.onError(asError(e));348 snapshot = [];349 }350 }351352 if (this.closed) {353 this.db?.close(); // same close()-race exit as above — never leak the connection354 this.db = null;355 return;356 }357 try {358 this.applySnapshot(snapshot);359 } catch (e) {360 this.onError(asError(e)); // degraded restore, never a broken client construction (P9)361 }362363 // Replay the buffered stream (gap-free by P1: anything broadcast pre-subscribe is in the364 // snapshot; anything after is in the buffer; the overlap is a mirror no-op, P3).365 this.live = true;366 for (const m of this.mirror.values()) {367 for (const [k, v] of m) if (v === null) m.delete(k); // sweep the pre-restore tombstones368 }369 const buffered = this.bootBuffer.splice(0);370 for (const msg of buffered) this.dispatch(msg);371 }372373 /** §4.6 step 4: apply the IDB snapshot through the mirror, one engine batch per table (§5.3).374 * Skips any pkKey the mirror already knows — a row written this session is newer than the375 * snapshot, and a pre-restore remove left a `null` tombstone, so `has()` covers both. Records376 * whose table is not a current local table are dropped — table-set schema evolution is handled377 * by data, not DDL (§3.1); the leader's promotion sweep deletes them from disk. */378 private applySnapshot(snapshot: StoredRow[]): void {379 const byTable = new Map<string, RowState[]>();380 for (const rec of snapshot) {381 const info = this.tables.get(rec.table);382 if (!info) continue; // stale table (dropped from the schema) — leader sweeps it at promotion383 if (!validRow(rec.row, info.width)) {384 this.onError(new Error(`local persistence: dropped a wrong-width row for "${rec.table}" (P7 shape guard)`));385 continue;386 }387 if (this.mirror.get(rec.table)!.has(rec.pkKey)) continue; // this session already wrote (or removed) it — newer388 let group = byTable.get(rec.table);389 if (!group) byTable.set(rec.table, (group = []));390 group.push({ table: rec.table, pkKey: rec.pkKey, row: rec.row });391 }392 for (const states of byTable.values()) this.applyStates(states);393 }394395 // -------------------------------------------------------------------------------------------396 // Origin writes: the tap (§4.2 roles)397 // -------------------------------------------------------------------------------------------398399 /** A committed `writeLocal` batch from THIS tab: derive row state, fold it into the mirror400 * synchronously (§4.4 — the engine already holds it), then route by role. Never throws (P9). */401 private onOriginWrite(muts: Mutation[]): void {402 if (this.disabled || this.closed) return;403 try {404 const states = this.statesFromMutations(muts);405 if (!states.length) return;406 const seq = ++this.opSeq;407 for (const s of states) {408 const m = this.mirror.get(s.table)!;409 if (s.row === null) {410 // Pre-live, a remove leaves a TOMBSTONE (`null`) so the §4.6 snapshot skip covers it;411 // once live the snapshot can't resurrect anything and plain deletion suffices.412 if (this.live) m.delete(s.pkKey);413 else m.set(s.pkKey, null);414 } else {415 m.set(s.pkKey, s.row);416 }417 this.pendingByPk.set(`${s.table}\0${s.pkKey}`, seq); // §4.2 hold-back until this commits418 }419 if (this.leader && this.leaderLive) {420 this.sequence(this.origin, seq, states);421 } else {422 // Follower (or leaderless / mid-boot): buffer unacked + forward. A leaderless op just423 // sits; the next `leader` announcement triggers the re-send (§4.5).424 this.unacked.set(seq, states);425 this.channel?.post({ t: "op", origin: this.origin, seq, batch: states } satisfies PersistMsg);426 }427 } catch (e) {428 this.onError(asError(e));429 }430 }431432 /** Mutation batch → idempotent row state (§4.3): add/edit → put(new) (a pk-changing edit becomes433 * tombstone(old) + put(new)); remove → tombstone. Later entries for one pk supersede earlier434 * ones by array order (applied in order everywhere). */435 private statesFromMutations(muts: Mutation[]): RowState[] {436 const out: RowState[] = [];437 for (const m of muts) {438 const info = this.tables.get(m.table);439 if (!info) continue; // a `local: "session"` table (§5.4) — this tab's business only440 if (m.op === "add") {441 out.push({ table: m.table, pkKey: pkKeyOf(m.row, info.pk), row: m.row });442 } else if (m.op === "remove") {443 out.push({ table: m.table, pkKey: pkKeyOf(m.row, info.pk), row: null });444 } else {445 const oldKey = pkKeyOf(m.old, info.pk);446 const newKey = pkKeyOf(m.new, info.pk);447 if (oldKey !== newKey) out.push({ table: m.table, pkKey: oldKey, row: null });448 out.push({ table: m.table, pkKey: newKey, row: m.new });449 }450 }451 return out;452 }453454 // -------------------------------------------------------------------------------------------455 // The mirror applier (§4.4, P3) — the ONLY path replicated state takes into the engine456 // -------------------------------------------------------------------------------------------457458 /** Apply row states through the mirror: diff against known state, emit the correct engine459 * delta, commit as ONE `applyLocalReplica` batch, and update the mirror at the engine's460 * post-commit anchor. Idempotent: matching state is a no-op. `staged` overlays the mirror461 * WITHIN the batch, so two states for one pk compose (add + edit), never collide.462 *463 * `commit` names the commit being applied (absent for restore/promotion folds). It drives the464 * §4.2 per-pk hold-back: while THIS tab has a newer uncommitted write to a pk, every other465 * state for it — the echo of our own earlier write, or a foreign write the leader sequenced466 * before ours — is stale relative to our tail; applying it would visibly rewind the row and467 * snap it forward again (one full persist round-trip per keystroke on the draft-typing path).468 * Skipped states skip engine AND mirror, which therefore stay in lockstep; the hold clears469 * when the pending write's own commit arrives, and convergence is untouched (a foreign write470 * sequenced AFTER ours applies normally once the hold is gone). */471 private applyStates(states: RowState[], commit?: { origin: string; seq: number }): void {472 const muts: Mutation[] = [];473 const mirrorOps: Array<() => void> = [];474 const staged = new Map<string, WireValue[] | null>(); // this batch's writes-so-far, by table\0pkKey475 for (const s of states) {476 const m = this.mirror.get(s.table);477 if (!m) {478 // A `local: "session"` table is OUTSIDE the plane (§5.4): drop silently — same posture479 // as a stale-table snapshot record (a mixed-version peer mid-deploy is normal, §3.1).480 if (this.sessionOnly.has(s.table)) continue;481 if (s.row !== null) {482 // Not a local table. Do NOT pre-filter: funnel it so `WasmBackend.writeLocal`'s M2483 // guard rejects it loudly (P8) — the batch dies whole, the mirror is untouched.484 muts.push({ op: "add", table: s.table, row: s.row });485 }486 continue;487 }488 const info = this.tables.get(s.table)!;489 const key = `${s.table}\0${s.pkKey}`;490 const pendingSeq = this.pendingByPk.get(key);491 if (pendingSeq !== undefined) {492 if (commit !== undefined && commit.origin === this.origin && commit.seq >= pendingSeq) {493 this.pendingByPk.delete(key); // our latest write to this pk is the one committing494 } else {495 continue; // §4.2 hold-back: a newer own write is still uncommitted496 }497 }498 const cur = staged.has(key) ? staged.get(key)! : (m.get(s.pkKey) ?? null);499 if (s.row === null) {500 if (cur) {501 muts.push({ op: "remove", table: s.table, row: cur });502 mirrorOps.push(() => m.delete(s.pkKey));503 staged.set(key, null);504 }505 } else if (!validRow(s.row, info.width)) {506 // Shape guard: a malformed replicated row must not reach the engine (wasm builds507 // panic=abort) — same test as the snapshot/promotion ingress points, deliberately shared.508 this.onError(new Error(`local persistence: dropped a malformed replicated row for "${s.table}"`));509 } else if (!cur) {510 const row = s.row;511 muts.push({ op: "add", table: s.table, row });512 mirrorOps.push(() => m.set(s.pkKey, row));513 staged.set(key, row);514 } else if (!rowsEqual(cur, s.row)) {515 const row = s.row;516 muts.push({ op: "edit", table: s.table, old: cur, new: row });517 mirrorOps.push(() => m.set(s.pkKey, row));518 staged.set(key, row);519 }520 }521 // The mirror updates run at the engine's post-commit anchor — after `tx.commit()`, BEFORE522 // subscriber delivery. A pre-commit rejection (M2) skips them with the engine untouched523 // (loud, P8); a subscriber throwing during delivery re-raises AFTER them — either way524 // mirror == engine. (Running them pre-delivery also means a subscriber that synchronously525 // writeLocal's a row in this batch lands its fresher mirror entry AFTER ours — never under.)526 if (muts.length) {527 this.backend.applyLocalReplica(muts, () => {528 for (const f of mirrorOps) f();529 });530 }531 }532533 // -------------------------------------------------------------------------------------------534 // The channel (§4.3)535 // -------------------------------------------------------------------------------------------536537 private onMessage(msg: PersistMsg): void {538 if (this.closed || !msg || typeof msg !== "object") return;539 if (!this.live) {540 this.bootBuffer.push(msg); // §4.6 step 1: buffer until the snapshot is in541 return;542 }543 this.dispatch(msg);544 }545546 private dispatch(msg: PersistMsg): void {547 try {548 if (msg.t === "op") {549 // Only a LIVE leader sequences ops; mid-promotion (lock held, not announced) they are550 // dropped — the sender re-sends on the `leader` announcement (§4.5).551 if (this.leader && this.leaderLive) this.sequence(msg.origin, msg.seq, msg.batch);552 } else if (msg.t === "commit") {553 if (msg.epoch < this.highestEpoch) return; // stale epoch (P6)554 this.highestEpoch = Math.max(this.highestEpoch, msg.epoch);555 this.noteDurability(msg.batch, msg.p !== false); // P9 bookkeeping (promotion repairs it)556 // Everyone applies, origin included (§4.2/P3) — modulo the per-pk hold-back, which skips557 // states superseded by this tab's own uncommitted tail (see applyStates).558 this.applyStates(msg.batch, { origin: msg.origin, seq: msg.seq });559 if (msg.origin === this.origin) this.unacked.delete(msg.seq); // the ack (P4)560 } else if (msg.t === "leader") {561 this.highestEpoch = Math.max(this.highestEpoch, msg.epoch);562 // P4: a new leader means our unacked ops may never have been persisted — re-send them563 // all, in seq order; mirror application makes any duplicate a no-op (P3).564 this.resendUnacked();565 }566 } catch (e) {567 this.onError(asError(e));568 }569 }570571 // -------------------------------------------------------------------------------------------572 // Leadership (§4.1, §4.5)573 // -------------------------------------------------------------------------------------------574575 /** The lock callback: the returned promise HOLDS the lock until close() (§4.1). Promotion runs576 * after our own restore completes; ops arriving meanwhile are dropped and re-sent on the577 * announcement (§4.5). */578 private onLockAcquired(): Promise<void> {579 if (this.closed) {580 // close() raced the grant (`abort()` no-ops once granted, and `releaseLock` was still581 // null when close ran): resolve immediately, or the exclusive lock is held until the tab582 // dies and every other tab for this user queues leaderless forever.583 return Promise.resolve();584 }585 const held = new Promise<void>((resolve) => {586 this.releaseLock = resolve;587 });588 void this.promote();589 return held;590 }591592 private async promote(): Promise<void> {593 try {594 await this.restored;595 if (this.closed) return;596 this.leader = true;597598 const pendingReset = await this.resetPending();599 // P6: monotonic epochs — above both the persisted high-water and any announcement heard.600 this.epoch = Math.max(this.epoch, this.highestEpoch) + 1;601 this.highestEpoch = this.epoch;602603 if (this.db) {604 if (pendingReset) {605 // P7, deferred from boot (P2 — only the leader writes): clear + new hash, one txn.606 await this.db.reset({ schemaHash: this.schemaHash, epoch: this.epoch });607 if (this.closed) return;608 this.notInIdb.clear(); // the store was just cleared; the seed below re-persists it all609 // Adopt our follower-era ops FIRST (their per-pk holds clear in original order), then610 // persist + announce the mirror itself: IDB was just cleared, so the "diff vs IDB"611 // below is vacuous — this session's live rows are all the data there is.612 this.adoptUnacked();613 const seed = this.mirrorStates();614 if (seed.length) this.sequence(this.origin, ++this.opSeq, seed);615 } else {616 await this.db.putMeta({ schemaHash: this.schemaHash, epoch: this.epoch });617 if (this.closed) return;618 await this.promotionDiff();619 }620 }621 if (this.closed) return;622623 // Adopt our OWN unacked ops (we were a follower): sequence + persist them ourselves, in624 // seq order. Anything the old leader did persist re-persists identically (idempotent).625 this.adoptUnacked();626627 // Announce LAST (§4.5 step 3): followers now re-send their unacked ops to us.628 this.leaderLive = true;629 this.channel?.post({ t: "leader", epoch: this.epoch } satisfies PersistMsg);630 } catch (e) {631 if (this.closed) return;632 this.onError(asError(e));633 // Storage failed mid-promotion: stay leader (the lock is ours) in broadcast-only degrade —634 // and STILL adopt our own unacked ops: no other tab can ever re-send them for us, and635 // sequence() never throws (its persist failure is the P9 catch), so coherence survives636 // even though this durability attempt didn't.637 this.adoptUnacked();638 this.leaderLive = true;639 this.channel?.post({ t: "leader", epoch: this.epoch } satisfies PersistMsg);640 }641 }642643 /** Adopt our follower-era unacked ops as leader: sequence + persist them ourselves, in seq644 * order (Map insertion order — seq is assigned at insert). Idempotent (the map is cleared). */645 private adoptUnacked(): void {646 for (const [seq, batch] of this.unacked) this.sequence(this.origin, seq, batch);647 this.unacked.clear();648 }649650 /** The P7 decision, made HERE — under the lock, where the read is authoritative (single651 * writer: no one can move meta between this read and our reset/putMeta). Boot's verdict alone652 * would be stale: an earlier same-version leader may have validly initialized or reset the653 * store since (its unannounced tail must then be diffed in, not wiped). If this read FAILS,654 * fall back to boot's verdict and fail CLOSED on anything but a clean boot match — the gate655 * must never stamp the new hash over unverified rows (that legitimizes old-shape data656 * forever; a wrongly-kept store self-heals via the promotion diff, a wrongly-stamped one657 * never does). */658 private async resetPending(): Promise<boolean> {659 if (!this.db) return false;660 try {661 const meta = await this.db.getMeta();662 if (meta?.schemaHash !== this.schemaHash) return true;663 // Matches (boot may have seen `error`/a pre-initialization store): nothing to clear. If664 // boot restored nothing, the promotion diff below folds every persisted row into the live665 // engine (the same P5 path that heals a dead leader's gap). Re-adopt the persisted epoch666 // high-water for P6 monotonicity.667 this.epoch = Math.max(this.epoch, meta.epoch);668 return false;669 } catch (e) {670 this.onError(asError(e));671 return this.bootMeta !== "match";672 }673 }674675 /** P5: on promotion, IDB — not any tab's memory — is the durable source of truth. Re-read it and676 * diff against the mirror; every difference (excluding keys our OWN unacked ops touch, which are677 * newer by definition and re-sequenced right after) is a persisted-but-unannounced write from678 * the dead leader's P1 gap: fold it into the mirror/engine and rebroadcast it under the new679 * epoch. Records for tables no longer in the schema are swept from disk here (§3.1).680 *681 * The P9 REPAIR runs first: for keys whose last commit never landed in IDB (`p: false`), the682 * mirror — not IDB — is the coherent truth; without the repair, the IDB gap would read as a683 * persisted-but-unannounced REMOVE below, and a one-off storage error on the old leader would684 * escalate into this promotion actively deleting committed, live rows from every tab. */685 private async promotionDiff(): Promise<void> {686 if (!this.db) return;687 await this.repairDurability();688 const persisted = await this.db.getAllRows();689 if (this.closed) return;690691 const ownUnacked = new Set<string>();692 for (const batch of this.unacked.values()) {693 for (const s of batch) ownUnacked.add(`${s.table}\0${s.pkKey}`);694 }695696 const diff: RowState[] = [];697 const stale: Array<{ table: string; pkKey: string }> = [];698 const seen = new Set<string>();699 for (const rec of persisted) {700 const info = this.tables.get(rec.table);701 if (!info) {702 stale.push({ table: rec.table, pkKey: rec.pkKey });703 continue;704 }705 const key = `${rec.table}\0${rec.pkKey}`;706 seen.add(key);707 if (ownUnacked.has(key)) continue;708 if (!validRow(rec.row, info.width)) {709 this.onError(new Error(`local persistence: dropped a malformed row for "${rec.table}" (promotion diff)`));710 continue;711 }712 const cur = this.mirror.get(rec.table)!.get(rec.pkKey);713 if (!cur || !rowsEqual(cur, rec.row)) diff.push({ table: rec.table, pkKey: rec.pkKey, row: rec.row });714 }715 // Mirror rows ABSENT from IDB (and not ours in flight): a persisted-but-unannounced REMOVE.716 // (Sound because the repair above already re-persisted every key IDB was known to be missing.)717 for (const [table, m] of this.mirror) {718 for (const [pkKey, row] of m) {719 if (row === null) continue; // a pre-restore tombstone (never reaches promotion, but cheap)720 const key = `${table}\0${pkKey}`;721 if (!seen.has(key) && !ownUnacked.has(key)) diff.push({ table, pkKey, row: null });722 }723 }724725 if (diff.length) {726 this.applyStates(diff); // fold into our own engine/mirror first (§4.5 step 2)727 // Already persisted (it CAME from IDB) — broadcast straight, no re-persist needed.728 this.channel?.post({729 t: "commit",730 epoch: this.epoch,731 lseq: ++this.lseq,732 origin: this.origin,733 seq: 0,734 batch: diff,735 p: true,736 } satisfies PersistMsg);737 }738 if (stale.length) await this.db.deleteRows(stale);739 }740741 /** P9 repair: make IDB match the mirror for every key flagged un-persisted (`notInIdb`). The742 * mirror holds the coherent truth for them — a present row re-puts, an absent one deletes.743 * Idempotent; throws propagate to the caller (a failed repair keeps the flags for next time). */744 private async repairDurability(): Promise<void> {745 if (!this.db || this.notInIdb.size === 0) return;746 const repair: RowState[] = [];747 for (const key of this.notInIdb) {748 const i = key.indexOf("\0");749 const table = key.slice(0, i);750 const pkKey = key.slice(i + 1);751 const m = this.mirror.get(table);752 if (!m) {753 this.notInIdb.delete(key); // the table left the plane — its records get swept anyway754 continue;755 }756 repair.push({ table, pkKey, row: m.get(pkKey) ?? null });757 }758 if (repair.length) await this.db.putBatch(repair);759 for (const s of repair) this.notInIdb.delete(`${s.table}\0${s.pkKey}`);760 }761762 /** P9 bookkeeping, updated from every commit (our own chain and the channel): `persisted: false`763 * marks the batch's keys as missing from IDB until a later successful persist covers them. */764 private noteDurability(batch: RowState[], persisted: boolean): void {765 for (const s of batch) {766 const key = `${s.table}\0${s.pkKey}`;767 if (persisted) this.notInIdb.delete(key);768 else this.notInIdb.add(key);769 }770 }771772 /** The full mirror as row state (the post-reset seed). Pre-restore tombstones are excluded —773 * they mark "removed before the snapshot landed", not durable rows. */774 private mirrorStates(): RowState[] {775 const out: RowState[] = [];776 for (const [table, m] of this.mirror) {777 for (const [pkKey, row] of m) if (row !== null) out.push({ table, pkKey, row });778 }779 return out;780 }781782 /** The leader's sequencer (§4.2): assign `(epoch, lseq)`, persist (ONE IDB txn), then broadcast783 * the `commit` (P1) and apply it locally through the mirror (a no-op for our own writes, which784 * the per-pk hold-back also shields from stale echoes). The chain serializes batches so IDB is785 * a fold of commits in `(epoch, lseq)` order (P2).786 *787 * Deliberately NOT gated on `closed`: a queued step carries an already-committed write —788 * cancelling it on an orderly close would silently drop durable data and its broadcast.789 * close() drains this chain and only then releases the lock and the handles. */790 private sequence(origin: string, seq: number, batch: RowState[]): void {791 const epoch = this.epoch;792 const lseq = ++this.lseq;793 this.persistChain = this.persistChain.then(async () => {794 let persisted = false;795 try {796 if (this.db) {797 await this.repairDurability(); // P9 backlog first, so IDB folds in commit order798 await this.db.putBatch(batch);799 persisted = true;800 }801 } catch (e) {802 this.onError(asError(e)); // durability degrades; coherence continues (P9)803 }804 this.noteDurability(batch, persisted);805 this.channel?.post({ t: "commit", epoch, lseq, origin, seq, batch, p: persisted } satisfies PersistMsg);806 try {807 this.applyStates(batch, { origin, seq });808 if (origin === this.origin) this.unacked.delete(seq);809 } catch (e) {810 this.onError(asError(e));811 }812 });813 }814815 // -------------------------------------------------------------------------------------------816 // Lifecycle817 // -------------------------------------------------------------------------------------------818819 async flush(): Promise<void> {820 // Best-effort final forward (§9), regardless of role: a mid-promotion leader still holds821 // un-adopted follower-era ops, and for a plain follower this is the last chance for a live822 // leader to persist them. A live leader's buffer is empty — the re-post is then a no-op.823 this.resendUnacked();824 if (this.leader) {825 await this.persistChain;826 try {827 await this.repairDurability(); // last-chance durability for P9-degraded batches828 } catch (e) {829 this.onError(asError(e));830 }831 }832 }833834 /** Re-post every unacked op, in seq order (Map insertion order — seq is assigned at insert). */835 private resendUnacked(): void {836 for (const [seq, batch] of this.unacked) {837 this.channel?.post({ t: "op", origin: this.origin, seq, batch } satisfies PersistMsg);838 }839 }840841 close(): void {842 if (this.closed) return;843 this.closed = true;844 this.lockAbort.abort(); // abandon a still-queued lock request845 // A follower's final forward (the pagehide flush's guarantee, now on the orderly path too):846 // re-post unacked ops while the channel is still open, so a live leader can persist them.847 if (!this.leader) this.resendUnacked();848 const finish = () => {849 this.releaseLock?.(); // release held leadership (P10)850 this.channel?.close();851 this.channel = null;852 this.db?.close();853 this.db = null;854 };855 if (this.leader) {856 // Drain, then release: queued persist steps carry already-COMMITTED writes (engine + every857 // tab's view have them) — cancelling would silently lose them from IDB and from every858 // other tab; and handing the lock over before the last putBatch lands would let the next859 // leader diff against a torn store. The chain never rejects (every step catches).860 void this.persistChain861 .then(() => this.repairDurability())862 .catch((e) => this.onError(asError(e)))863 .then(finish);864 } else {865 finish();866 }867 }868}869870// ---------------------------------------------------------------------------------------------871// Helpers872// ---------------------------------------------------------------------------------------------873874/** `pkKey` (§3.1): JSON of the pk cells in schema pk order — a STRING deliberately (IDB keys875 * admit neither `null` nor `boolean`, both legal rindle pk cells). Non-finite numbers get a876 * NUL-prefixed tag: `JSON.stringify` folds NaN/±Infinity into `null`, which would collapse877 * engine-distinct rows onto one storage/replication key (a remove of either would tombstone878 * both). The NUL byte keeps a legitimate string cell from ever colliding with the tag. */879function pkKeyOf(row: WireValue[], pk: number[]): string {880 return JSON.stringify(881 pk.map((i) => {882 const v = row[i] ?? null;883 return typeof v === "number" && !Number.isFinite(v) ? `\u0000n:${String(v)}` : v;884 }),885 );886}887888/** The replicated-row shape guard, shared by ALL three ingress points (snapshot restore, live889 * commits, the promotion diff) so they can never drift: a malformed row must not reach the890 * engine (wasm builds panic=abort). */891function validRow(row: unknown, width: number): row is WireValue[] {892 return Array.isArray(row) && row.length === width;893}894895function asError(e: unknown): Error {896 return e instanceof Error ? e : new Error(String(e));897}898899function mintOrigin(): string {900 const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;901 if (c?.randomUUID) return c.randomUUID();902 return `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;903}904905// ---------------------------------------------------------------------------------------------906// The default (real-browser) environment — structural typing over the globals (no DOM lib here)907// ---------------------------------------------------------------------------------------------908909// The slivers of the IDB/locks/BroadcastChannel surfaces this module touches.910interface IdbRequestLike<T = unknown> {911 result: T;912 error?: unknown;913 onsuccess: ((ev: unknown) => void) | null;914 onerror: ((ev: unknown) => void) | null;915}916interface IdbOpenRequestLike extends IdbRequestLike<IdbDatabaseLike> {917 onupgradeneeded: ((ev: unknown) => void) | null;918 onblocked: ((ev: unknown) => void) | null;919}920interface IdbObjectStoreLike {921 put(value: unknown, key?: unknown): IdbRequestLike;922 delete(key: unknown): IdbRequestLike;923 get(key: unknown): IdbRequestLike;924 getAll(): IdbRequestLike<unknown[]>;925 clear(): IdbRequestLike;926}927interface IdbTransactionLike {928 objectStore(name: string): IdbObjectStoreLike;929 oncomplete: ((ev: unknown) => void) | null;930 onerror: ((ev: unknown) => void) | null;931 onabort: ((ev: unknown) => void) | null;932 error?: unknown;933}934interface IdbDatabaseLike {935 transaction(stores: string[], mode: "readonly" | "readwrite"): IdbTransactionLike;936 createObjectStore(name: string, opts?: { keyPath?: string | string[] }): unknown;937 objectStoreNames: { contains(name: string): boolean };938 onversionchange: ((ev: unknown) => void) | null;939 close(): void;940}941interface IdbFactoryLike {942 open(name: string, version: number): IdbOpenRequestLike;943 deleteDatabase(name: string): IdbRequestLike;944}945interface BroadcastChannelLike {946 postMessage(msg: unknown): void;947 onmessage: ((ev: { data: unknown }) => void) | null;948 close(): void;949}950interface LockManagerLike {951 request(name: string, opts: { mode: "exclusive"; signal?: AbortSignal }, cb: () => Promise<void>): Promise<unknown>;952}953954const ROWS = "rows";955const META = "meta";956const META_KEY = "meta";957958/** Wrap one IDB transaction's completion as a promise (resolution == txn durably committed —959 * the exact anchor P1 needs). */960function txnDone(txn: IdbTransactionLike): Promise<void> {961 return new Promise((resolve, reject) => {962 txn.oncomplete = () => resolve();963 txn.onerror = () => reject(asError(txn.error ?? new Error("IndexedDB transaction failed")));964 txn.onabort = () => reject(asError(txn.error ?? new Error("IndexedDB transaction aborted")));965 });966}967968function reqDone<T>(req: IdbRequestLike<T>): Promise<T> {969 return new Promise((resolve, reject) => {970 req.onsuccess = () => resolve(req.result);971 req.onerror = () => reject(asError(req.error ?? new Error("IndexedDB request failed")));972 });973}974975class IdbPersistDb implements PersistDb {976 private readonly db: IdbDatabaseLike;977978 constructor(db: IdbDatabaseLike) {979 this.db = db;980 }981982 async getMeta(): Promise<PersistMeta | undefined> {983 const txn = this.db.transaction([META], "readonly");984 const out = await reqDone(txn.objectStore(META).get(META_KEY));985 return out as PersistMeta | undefined;986 }987988 putMeta(meta: PersistMeta): Promise<void> {989 const txn = this.db.transaction([META], "readwrite");990 txn.objectStore(META).put(meta, META_KEY);991 return txnDone(txn);992 }993994 async getAllRows(): Promise<StoredRow[]> {995 const txn = this.db.transaction([ROWS], "readonly");996 const out = await reqDone(txn.objectStore(ROWS).getAll());997 return out as StoredRow[];998 }9991000 putBatch(batch: RowState[]): Promise<void> {1001 const txn = this.db.transaction([ROWS], "readwrite");1002 const store = txn.objectStore(ROWS);1003 for (const s of batch) {1004 if (s.row === null) store.delete([s.table, s.pkKey]);1005 else store.put({ table: s.table, pkKey: s.pkKey, row: s.row } satisfies StoredRow);1006 }1007 return txnDone(txn);1008 }10091010 reset(meta: PersistMeta): Promise<void> {1011 const txn = this.db.transaction([ROWS, META], "readwrite");1012 txn.objectStore(ROWS).clear();1013 txn.objectStore(META).put(meta, META_KEY);1014 return txnDone(txn);1015 }10161017 deleteRows(keys: Array<{ table: string; pkKey: string }>): Promise<void> {1018 const txn = this.db.transaction([ROWS], "readwrite");1019 const store = txn.objectStore(ROWS);1020 for (const k of keys) store.delete([k.table, k.pkKey]);1021 return txnDone(txn);1022 }10231024 close(): void {1025 this.db.close();1026 }1027}10281029/** How long a BLOCKED IDB open may park before the layer degrades to broadcast-only: a pending1030 * `deleteDatabase` (a sibling tab's logout) queues every later open behind it, and an old-version1031 * connection with no `versionchange` handler can hold that queue indefinitely — client1032 * construction must never hang on it. */1033const OPEN_BLOCKED_TIMEOUT_MS = 2000;10341035/** The real-browser environment: IndexedDB + BroadcastChannel + `navigator.locks`, each reached1036 * structurally and each degrading gracefully when absent (§3.2). */1037export function defaultEnv(): PersistEnv {1038 const g = globalThis as unknown as {1039 indexedDB?: IdbFactoryLike;1040 BroadcastChannel?: new (name: string) => BroadcastChannelLike;1041 navigator?: { locks?: LockManagerLike; storage?: { persist?: () => Promise<boolean> } };1042 };1043 const locks = g.navigator?.locks;1044 return {1045 // Multi-context runtime with no exclusive lock (Firefox <96, Safari 15.1–15.3, Node): the1046 // solo-mode fallback below would make EVERY context a leader — the layer must run inert.1047 leaderElectionUnavailable: !locks && typeof g.BroadcastChannel === "function",1048 openDatabase(name: string): Promise<PersistDb | null> {1049 const idb = g.indexedDB;1050 if (!idb) return Promise.resolve(null);1051 return new Promise((resolve) => {1052 let settled = false;1053 const settle = (db: PersistDb | null) => {1054 if (settled) return;1055 settled = true;1056 resolve(db);1057 };1058 let req: IdbOpenRequestLike;1059 try {1060 req = idb.open(name, 1);1061 } catch {1062 resolve(null); // some privacy modes throw synchronously — degrade (§3.2)1063 return;1064 }1065 req.onupgradeneeded = () => {1066 // Version 1 forever (§3.1): a fixed two-store shape, so a schema's table-set change1067 // never needs an IDB version bump.1068 const db = req.result;1069 if (!db.objectStoreNames.contains(ROWS)) db.createObjectStore(ROWS, { keyPath: ["table", "pkKey"] });1070 if (!db.objectStoreNames.contains(META)) db.createObjectStore(META);1071 };1072 req.onsuccess = () => {1073 const db = req.result;1074 // A sibling tab's deleteDatabase (logout) fires `versionchange` at every open1075 // connection and then WAITS for them all to close: release ours so the delete (and1076 // any open queued behind it) can proceed — this session degrades (P9), the logout1077 // completes. Without this, one live tab wedges every other tab's logout forever.1078 db.onversionchange = () => db.close();1079 if (settled) {1080 db.close(); // the blocked-open timeout already degraded us — don't hold a handle1081 return;1082 }1083 settle(new IdbPersistDb(db));1084 };1085 req.onerror = () => settle(null); // unavailable → broadcast-only degrade (§3.2)1086 req.onblocked = () => {1087 // Parked behind a pending delete (or an old-version connection): degrade after a1088 // bounded wait rather than hanging client construction — never forever.1089 setTimeout(() => settle(null), OPEN_BLOCKED_TIMEOUT_MS);1090 };1091 });1092 },1093 deleteDatabase(name: string): Promise<void> {1094 const idb = g.indexedDB;1095 if (!idb) return Promise.resolve();1096 return reqDone(idb.deleteDatabase(name)).then(() => undefined);1097 },1098 createChannel(name: string): PersistChannel | null {1099 const BC = g.BroadcastChannel;1100 if (!BC) return null;1101 const ch = new BC(name);1102 return {1103 post: (msg) => {1104 try {1105 ch.postMessage(msg);1106 } catch {1107 /* a closed/failed channel loses coherence, never the write path (P9) */1108 }1109 },1110 onMessage: (handler) => {1111 ch.onmessage = (ev) => handler(ev.data);1112 },1113 close: () => ch.close(),1114 };1115 },1116 requestLock(name: string, signal: AbortSignal, onAcquired: () => Promise<void>): void {1117 if (!locks) {1118 // No Web Locks AND no BroadcastChannel (`leaderElectionUnavailable` gates the layer off1119 // otherwise): a provably single-context runtime — solo mode, safe by construction.1120 void onAcquired();1121 return;1122 }1123 void locks.request(name, { mode: "exclusive", signal }, onAcquired).catch(() => {1124 /* an aborted queued request (close before acquisition) — expected */1125 });1126 },1127 requestPersistentStorage(): void {1128 void g.navigator?.storage?.persist?.()?.catch?.(() => {});1129 },1130 };1131}1132