Rindle

API index and search · Build metadata

Source snapshot

packages/optimistic/src/local-persist.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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