@rindle/optimistic 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1129 @@
1
+ // Local-table persistence (207-LOCAL-TABLE-PERSISTENCE-DESIGN.md): durable, cross-tab
2
+ // 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's
10
+ // 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 writes
12
+ // immediately, forward row-state ops to the leader, and hold them unacked until the matching
13
+ // 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 against
15
+ // known state and emits the correct engine add/edit/remove (§4.4, P3) — resend, replay, and
16
+ // snapshot/stream overlap are all no-ops by construction.
17
+ //
18
+ // Browser globals are reached via structural typing (this package has no DOM lib — precedent
19
+ // `client.ts:154-158`); the same structural seam (`PersistEnv`) is what the tests inject a fake
20
+ // IDB/channel/locks environment through.
21
+
22
+ import { localSchemaHash, persistedLocalTableNames, rowsEqual, tableSpec } from "@rindle/client";
23
+ import type { ColsMap, Mutation, Schema, WireValue } from "@rindle/client";
24
+
25
+ import type { OptimisticBackend } from "./backend.ts";
26
+
27
+ // ---------------------------------------------------------------------------------------------
28
+ // Public option/handle surface (§5.2)
29
+ // ---------------------------------------------------------------------------------------------
30
+
31
+ export 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
+ /** IDB/channel failures surface here (P9 — durability degrades, the write path never throws).
38
+ * Default `console.error`. */
39
+ onError?: (e: Error) => void;
40
+ /** Test seam: a fake IDB/locks/channel environment. Defaults to the browser globals. */
41
+ env?: PersistEnv;
42
+ }
43
+
44
+ /** The attached layer's handle. `createRindleClient` awaits {@link ready} before returning (§5.2). */
45
+ export interface LocalPersistence {
46
+ /** Resolves when the initial restore (§4.6 steps 1–5) has completed and the layer is live. */
47
+ readonly ready: Promise<void>;
48
+ /** Best-effort final persist/forward (the `pagehide` hook, §9): re-posts unacked ops (any
49
+ * role), then a leader drains its persist queue and retries P9-degraded batches. */
50
+ flush(): Promise<void>;
51
+ /** Release leadership (or abandon the queued lock request), close the channel + IDB (P10).
52
+ * A leader DRAINS its queued persist steps first (they carry already-committed writes) and
53
+ * releases the lock only after the last one lands; a follower re-posts its unacked ops. */
54
+ close(): void;
55
+ /** Introspection for tests/devtools: current role. */
56
+ role(): "leader" | "follower";
57
+ }
58
+
59
+ /** Delete a user's local-persistence database — the sanctioned LOGOUT hook (§3.2). Never called
60
+ * implicitly; the old database otherwise remains on disk (fast re-login, and a privacy decision
61
+ * the app owns). Close this tab's live client for `user` first; a SIBLING tab's connection is
62
+ * released automatically (it closes on `versionchange` and degrades to broadcast-only), so a
63
+ * multi-tab logout completes instead of parking behind the other tab forever. */
64
+ export function deleteLocalPersistence(user: string, env?: PersistEnv): Promise<void> {
65
+ return (env ?? defaultEnv()).deleteDatabase(dbName(user));
66
+ }
67
+
68
+ /** Attach the persistence layer to a backend (standalone form — `createRindleClient` calls this
69
+ * for you). The observer is wired SYNCHRONOUSLY, but the mirror starts empty: attach before the
70
+ * first `writeLocal` (§5.2, a v1 constraint `createRindleClient` satisfies by construction).
71
+ * Await `handle.ready` before first render if the app wants restored rows at first paint. */
72
+ export function attachLocalPersistence<S extends ColsMap>(
73
+ backend: OptimisticBackend<S>,
74
+ schema: Schema<S>,
75
+ opts: PersistLocalOptions,
76
+ ): LocalPersistence {
77
+ return new LocalPersistLayer(backend, schema, opts);
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------------------------
81
+ // The environment seam — the browser surface, structurally typed (real by default, faked in tests)
82
+ // ---------------------------------------------------------------------------------------------
83
+
84
+ /** The narrow storage surface the layer needs (§3.1): two fixed stores, `rows` + `meta`. Each
85
+ * method is one atomic IDB transaction; a resolved promise means the txn COMPLETED — the P1
86
+ * ("persist-then-broadcast") anchor. */
87
+ export interface PersistDb {
88
+ getMeta(): Promise<PersistMeta | undefined>;
89
+ putMeta(meta: PersistMeta): Promise<void>;
90
+ getAllRows(): Promise<StoredRow[]>;
91
+ /** Apply one commit batch atomically: `row === null` deletes, else puts. */
92
+ putBatch(batch: RowState[]): Promise<void>;
93
+ /** The P7 gate: clear `rows` and write `meta` in ONE transaction (never a half state). */
94
+ reset(meta: PersistMeta): Promise<void>;
95
+ /** Delete specific records (the leader's stale-table sweep, §3.1). */
96
+ deleteRows(keys: Array<{ table: string; pkKey: string }>): Promise<void>;
97
+ close(): void;
98
+ }
99
+
100
+ export interface PersistChannel {
101
+ post(msg: unknown): void;
102
+ onMessage(handler: (msg: unknown) => void): void;
103
+ close(): void;
104
+ }
105
+
106
+ export interface PersistEnv {
107
+ /** Open (creating if needed) the database. Resolves `null` when storage is unavailable —
108
+ * the layer degrades to broadcast-only (§3.2). */
109
+ openDatabase(name: string): Promise<PersistDb | null>;
110
+ deleteDatabase(name: string): Promise<void>;
111
+ /** Open the tab-coherence channel; `null` when BroadcastChannel is unavailable (single-tab). */
112
+ createChannel(name: string): PersistChannel | null;
113
+ /** Queue for the exclusive leadership lock (§4.1): `onAcquired`'s promise HOLDS the lock until
114
+ * it resolves; `signal` aborts a still-queued request. When Web Locks are unavailable, grant
115
+ * immediately ONLY if the runtime is provably single-context (no channel) — see
116
+ * {@link leaderElectionUnavailable}. */
117
+ requestLock(name: string, signal: AbortSignal, onAcquired: () => Promise<void>): void;
118
+ /** The runtime has tabs (a channel) but NO exclusive lock (Firefox <96, Safari 15.1–15.3,
119
+ * Node): every context would self-promote into concurrent leaders over one database — P2
120
+ * violated, permanent divergence. When set, the layer runs INERT: local tables still work,
121
+ * session-scoped (the 201 baseline), with no persistence and no cross-tab replication. */
122
+ leaderElectionUnavailable?: boolean;
123
+ /** `navigator.storage.persist()` (§3.2), best-effort. */
124
+ requestPersistentStorage?(): void;
125
+ }
126
+
127
+ export interface PersistMeta {
128
+ schemaHash: string;
129
+ epoch: number;
130
+ }
131
+
132
+ export interface StoredRow {
133
+ table: string;
134
+ pkKey: string;
135
+ row: WireValue[];
136
+ }
137
+
138
+ /** The replication unit everywhere (§4.3): idempotent full-row state. `row: null` = tombstone
139
+ * (live protocol only — removes DELETE the IDB record; there are no persisted tombstones). */
140
+ export interface RowState {
141
+ table: string;
142
+ pkKey: string;
143
+ row: WireValue[] | null;
144
+ }
145
+
146
+ // The three messages (§4.3), one BroadcastChannel per (origin, user, localSchemaHash). `p` on a
147
+ // commit = "this batch is durably in IDB" (false ⇒ the P9 degrade fired): every tab tracks the
148
+ // un-persisted keys so a later promotion REPAIRS them from the mirror instead of misreading the
149
+ // IDB gap as a remove.
150
+ type PersistMsg =
151
+ | { t: "op"; origin: string; seq: number; batch: RowState[] }
152
+ | { t: "commit"; epoch: number; lseq: number; origin: string; seq: number; batch: RowState[]; p: boolean }
153
+ | { t: "leader"; epoch: number };
154
+
155
+ function dbName(user: string): string {
156
+ return `rindle-local:${user}`;
157
+ }
158
+
159
+ /** The channel is partitioned by schema hash (the lock + database stay per-user): a mixed-version
160
+ * peer mid-deploy must never replicate old-shape rows into a new-schema engine — the width guard
161
+ * cannot catch a same-width reshape (a column rename or retype), so cross-version traffic is made
162
+ * structurally impossible instead. The old-version leader keeps the shared lock until it closes;
163
+ * new-version tabs run local-first meanwhile and adopt their unacked ops at promotion (§4.5). */
164
+ function channelName(user: string, schemaHash: string): string {
165
+ return `${dbName(user)}::${schemaHash}`;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------------------------
169
+ // The layer
170
+ // ---------------------------------------------------------------------------------------------
171
+
172
+ interface LocalTableInfo {
173
+ /** pk column indices, in schema pk order. */
174
+ pk: number[];
175
+ /** Full positional row width — the shape guard for replicated rows (a wrong-width row must not
176
+ * reach the wasm engine, which builds `panic = "abort"`). */
177
+ width: number;
178
+ }
179
+
180
+ class LocalPersistLayer<S extends ColsMap> implements LocalPersistence {
181
+ readonly ready: Promise<void>;
182
+
183
+ private readonly backend: OptimisticBackend<S>;
184
+ private readonly onError: (e: Error) => void;
185
+ private readonly env: PersistEnv;
186
+ /** Per PERSISTED local table (`local: true` — a `local: "session"` table stays outside the
187
+ * plane, §5.4): pk extraction + width (from the same schema meta `tableSpec` reads, §5.3). */
188
+ private readonly tables = new Map<string, LocalTableInfo>();
189
+ private readonly schemaHash: string;
190
+
191
+ /** The §4.4 mirror: per PERSISTED local table, pkKey → engine row — the JS twin of the local
192
+ * source. A `null` value is a PRE-RESTORE TOMBSTONE (§4.6 step 4): a remove issued before the
193
+ * snapshot landed must not be resurrected by it (`has()` covers both shapes); swept once live. */
194
+ private readonly mirror = new Map<string, Map<string, WireValue[] | null>>();
195
+ /** `local: "session"` table names (§5.4) — inbound states naming one are dropped, never
196
+ * funneled to the engine (M2 admits local tables, so the isolation guard lives here). */
197
+ private readonly sessionOnly = new Set<string>();
198
+
199
+ /** This layer instance's identity on the channel (NOT the clientID — two clients in one tab are
200
+ * two "tabs" here, §9). */
201
+ private readonly origin: string;
202
+ private channel: PersistChannel | null = null;
203
+ private db: PersistDb | null = null;
204
+
205
+ // --- role / election state (§4.1–§4.2) ---
206
+ private leader = false;
207
+ /** Leader-only: announcement broadcast; incoming `op`s are processed. Before this (mid-promotion)
208
+ * ops are dropped — the sender re-sends on the `leader` announcement (§4.5). */
209
+ private leaderLive = false;
210
+ private releaseLock: (() => void) | null = null;
211
+ private readonly lockAbort = new AbortController();
212
+ private epoch = 0;
213
+ /** Highest `leader`/`commit` epoch seen; commits below it are discarded (P6). */
214
+ private highestEpoch = 0;
215
+ private lseq = 0;
216
+ /** Leader-only: the serialized persist→broadcast chain (P1/P2 — one writer, one order). */
217
+ private persistChain: Promise<void> = Promise.resolve();
218
+
219
+ // --- follower state (P4) ---
220
+ private opSeq = 0;
221
+ /** Ops applied locally + forwarded, held until the matching `commit` acks them (§4.5). */
222
+ private readonly unacked = new Map<number, RowState[]>();
223
+ /** Per-pk hold-back (§4.2): `table\0pkKey` → the seq of this tab's LATEST uncommitted write to
224
+ * that pk. While set, any other state for the pk (our own earlier echo, or a foreign write the
225
+ * leader sequenced before ours) is stale relative to our tail — applying it would visibly
226
+ * rewind the row and then snap it forward again. Cleared when the write's own commit applies. */
227
+ private readonly pendingByPk = new Map<string, number>();
228
+ /** P9 bookkeeping: `table\0pkKey`s whose LAST commit was broadcast with `p: false` (the persist
229
+ * failed or there is no IDB) — IDB does not reflect the coherent state for them. A leader
230
+ * retries them from the mirror on later chain steps; a promotion REPAIRS them before diffing,
231
+ * so the IDB gap is never misread as a persisted-but-unannounced remove. */
232
+ private readonly notInIdb = new Set<string>();
233
+
234
+ // --- boot state (§4.6) ---
235
+ private live = false;
236
+ private readonly bootBuffer: PersistMsg[] = [];
237
+ /** Boot's meta verdict — the FALLBACK for the P7 decision when promotion's authoritative
238
+ * re-read (under the lock) fails: anything but a clean boot `match` then fails CLOSED
239
+ * (reset), because stamping the new hash over unverified rows would legitimize old-shape
240
+ * data forever. `error` = boot couldn't read meta (nothing was restored). */
241
+ private bootMeta: "match" | "mismatch" | "error" = "error";
242
+ /** Resolves once the §4.6 restore has run. A field initializer (not a `start()` return) so it
243
+ * exists BEFORE the lock request — an immediately-granted lock (solo mode / a free queue) must
244
+ * still park its promotion behind the restore. */
245
+ private restoreDone!: () => void;
246
+ private readonly restored = new Promise<void>((r) => {
247
+ this.restoreDone = r;
248
+ });
249
+ private closed = false;
250
+ /** {@link PersistEnv.leaderElectionUnavailable}: the layer is attached but INERT. */
251
+ private readonly disabled: boolean;
252
+
253
+ constructor(backend: OptimisticBackend<S>, schema: Schema<S>, opts: PersistLocalOptions) {
254
+ this.backend = backend;
255
+ this.env = opts.env ?? defaultEnv();
256
+ this.onError = opts.onError ?? ((e) => console.error("[rindle] local persistence:", e));
257
+ this.schemaHash = localSchemaHash(schema);
258
+ this.origin = mintOrigin();
259
+ for (const name of persistedLocalTableNames(schema)) {
260
+ const spec = tableSpec(schema.tables[name]);
261
+ this.tables.set(name, { pk: spec.primaryKey, width: spec.columns.length });
262
+ this.mirror.set(name, new Map());
263
+ }
264
+ // `local: "session"` tables (§5.4): local for every 201 rule, but OUTSIDE this plane — their
265
+ // writes are never forwarded/persisted, and an inbound state naming one (a mixed-version
266
+ // peer) must be dropped HERE: M2 would happily admit it (it IS local), which would break the
267
+ // per-tab isolation being ephemeral promises.
268
+ for (const name of Object.keys(schema.tables)) {
269
+ if (schema.tables[name].local === "session") this.sessionOnly.add(name);
270
+ }
271
+
272
+ // The write-through tap (§5.1): committed origin writes → row state → mirror, then routed by
273
+ // role. Wired synchronously so no write can slip past the layer (the mirror starts empty).
274
+ backend.onLocalWrite((muts) => this.onOriginWrite(muts));
275
+
276
+ if (this.env.leaderElectionUnavailable) {
277
+ // Tabs without an exclusive lock would all self-promote: concurrent leaders over one
278
+ // database, P2 violated, permanent divergence. Run inert instead — local tables keep
279
+ // working, session-scoped (the 201 baseline).
280
+ this.disabled = true;
281
+ console.warn(
282
+ "[rindle] Web Locks unavailable — local-table persistence disabled (local tables are session-scoped).",
283
+ );
284
+ this.ready = Promise.resolve();
285
+ this.restoreDone();
286
+ return;
287
+ }
288
+ this.disabled = false;
289
+
290
+ if (opts.requestPersistentStorage) this.env.requestPersistentStorage?.();
291
+
292
+ // §4.6 attach order: (1) channel first — start buffering; (2) queue for the lock; (3–5) open
293
+ // IDB, snapshot, replay (in start()).
294
+ this.channel = this.env.createChannel(channelName(opts.user, this.schemaHash));
295
+ this.channel?.onMessage((msg) => this.onMessage(msg as PersistMsg));
296
+ this.env.requestLock(`rindle-local-leader:${opts.user}`, this.lockAbort.signal, () => this.onLockAcquired());
297
+
298
+ this.ready = this.start(opts.user);
299
+ void this.ready.then(this.restoreDone);
300
+ }
301
+
302
+ role(): "leader" | "follower" {
303
+ return this.leader ? "leader" : "follower";
304
+ }
305
+
306
+ // -------------------------------------------------------------------------------------------
307
+ // Boot: subscribe → snapshot → replay (§4.6)
308
+ // -------------------------------------------------------------------------------------------
309
+
310
+ private async start(user: string): Promise<void> {
311
+ try {
312
+ this.db = await this.env.openDatabase(dbName(user));
313
+ } catch (e) {
314
+ this.db = null;
315
+ this.onError(asError(e));
316
+ }
317
+ if (this.closed) {
318
+ // close() raced the open (it ran with db still null): release the fresh handle here or it
319
+ // holds the database open forever, blocking every later deleteLocalPersistence.
320
+ this.db?.close();
321
+ this.db = null;
322
+ return;
323
+ }
324
+ if (!this.db) {
325
+ console.warn("[rindle] IndexedDB unavailable — local tables are session-scoped (broadcast-only).");
326
+ }
327
+
328
+ let snapshot: StoredRow[] = [];
329
+ if (this.db) {
330
+ try {
331
+ const meta = await this.db.getMeta();
332
+ if (meta?.schemaHash === this.schemaHash) {
333
+ this.bootMeta = "match";
334
+ this.epoch = meta.epoch;
335
+ this.highestEpoch = Math.max(this.highestEpoch, meta.epoch);
336
+ snapshot = await this.db.getAllRows();
337
+ } else {
338
+ // P7: mismatch (or missing meta) ⇒ start empty. The on-disk CLEAR is deferred to
339
+ // promotion (only the lock holder writes IDB — P2); until then we simply load nothing.
340
+ this.bootMeta = "mismatch";
341
+ this.epoch = meta?.epoch ?? 0;
342
+ }
343
+ } catch (e) {
344
+ this.bootMeta = "error"; // nothing restored; promotion must fail CLOSED on this store
345
+ this.onError(asError(e));
346
+ snapshot = [];
347
+ }
348
+ }
349
+
350
+ if (this.closed) {
351
+ this.db?.close(); // same close()-race exit as above — never leak the connection
352
+ this.db = null;
353
+ return;
354
+ }
355
+ try {
356
+ this.applySnapshot(snapshot);
357
+ } catch (e) {
358
+ this.onError(asError(e)); // degraded restore, never a broken client construction (P9)
359
+ }
360
+
361
+ // Replay the buffered stream (gap-free by P1: anything broadcast pre-subscribe is in the
362
+ // snapshot; anything after is in the buffer; the overlap is a mirror no-op, P3).
363
+ this.live = true;
364
+ for (const m of this.mirror.values()) {
365
+ for (const [k, v] of m) if (v === null) m.delete(k); // sweep the pre-restore tombstones
366
+ }
367
+ const buffered = this.bootBuffer.splice(0);
368
+ for (const msg of buffered) this.dispatch(msg);
369
+ }
370
+
371
+ /** §4.6 step 4: apply the IDB snapshot through the mirror, one engine batch per table (§5.3).
372
+ * Skips any pkKey the mirror already knows — a row written this session is newer than the
373
+ * snapshot, and a pre-restore remove left a `null` tombstone, so `has()` covers both. Records
374
+ * whose table is not a current local table are dropped — table-set schema evolution is handled
375
+ * by data, not DDL (§3.1); the leader's promotion sweep deletes them from disk. */
376
+ private applySnapshot(snapshot: StoredRow[]): void {
377
+ const byTable = new Map<string, RowState[]>();
378
+ for (const rec of snapshot) {
379
+ const info = this.tables.get(rec.table);
380
+ if (!info) continue; // stale table (dropped from the schema) — leader sweeps it at promotion
381
+ if (!validRow(rec.row, info.width)) {
382
+ this.onError(new Error(`local persistence: dropped a wrong-width row for "${rec.table}" (P7 shape guard)`));
383
+ continue;
384
+ }
385
+ if (this.mirror.get(rec.table)!.has(rec.pkKey)) continue; // this session already wrote (or removed) it — newer
386
+ let group = byTable.get(rec.table);
387
+ if (!group) byTable.set(rec.table, (group = []));
388
+ group.push({ table: rec.table, pkKey: rec.pkKey, row: rec.row });
389
+ }
390
+ for (const states of byTable.values()) this.applyStates(states);
391
+ }
392
+
393
+ // -------------------------------------------------------------------------------------------
394
+ // Origin writes: the tap (§4.2 roles)
395
+ // -------------------------------------------------------------------------------------------
396
+
397
+ /** A committed `writeLocal` batch from THIS tab: derive row state, fold it into the mirror
398
+ * synchronously (§4.4 — the engine already holds it), then route by role. Never throws (P9). */
399
+ private onOriginWrite(muts: Mutation[]): void {
400
+ if (this.disabled || this.closed) return;
401
+ try {
402
+ const states = this.statesFromMutations(muts);
403
+ if (!states.length) return;
404
+ const seq = ++this.opSeq;
405
+ for (const s of states) {
406
+ const m = this.mirror.get(s.table)!;
407
+ if (s.row === null) {
408
+ // Pre-live, a remove leaves a TOMBSTONE (`null`) so the §4.6 snapshot skip covers it;
409
+ // once live the snapshot can't resurrect anything and plain deletion suffices.
410
+ if (this.live) m.delete(s.pkKey);
411
+ else m.set(s.pkKey, null);
412
+ } else {
413
+ m.set(s.pkKey, s.row);
414
+ }
415
+ this.pendingByPk.set(`${s.table}\0${s.pkKey}`, seq); // §4.2 hold-back until this commits
416
+ }
417
+ if (this.leader && this.leaderLive) {
418
+ this.sequence(this.origin, seq, states);
419
+ } else {
420
+ // Follower (or leaderless / mid-boot): buffer unacked + forward. A leaderless op just
421
+ // sits; the next `leader` announcement triggers the re-send (§4.5).
422
+ this.unacked.set(seq, states);
423
+ this.channel?.post({ t: "op", origin: this.origin, seq, batch: states } satisfies PersistMsg);
424
+ }
425
+ } catch (e) {
426
+ this.onError(asError(e));
427
+ }
428
+ }
429
+
430
+ /** Mutation batch → idempotent row state (§4.3): add/edit → put(new) (a pk-changing edit becomes
431
+ * tombstone(old) + put(new)); remove → tombstone. Later entries for one pk supersede earlier
432
+ * ones by array order (applied in order everywhere). */
433
+ private statesFromMutations(muts: Mutation[]): RowState[] {
434
+ const out: RowState[] = [];
435
+ for (const m of muts) {
436
+ const info = this.tables.get(m.table);
437
+ if (!info) continue; // a `local: "session"` table (§5.4) — this tab's business only
438
+ if (m.op === "add") {
439
+ out.push({ table: m.table, pkKey: pkKeyOf(m.row, info.pk), row: m.row });
440
+ } else if (m.op === "remove") {
441
+ out.push({ table: m.table, pkKey: pkKeyOf(m.row, info.pk), row: null });
442
+ } else {
443
+ const oldKey = pkKeyOf(m.old, info.pk);
444
+ const newKey = pkKeyOf(m.new, info.pk);
445
+ if (oldKey !== newKey) out.push({ table: m.table, pkKey: oldKey, row: null });
446
+ out.push({ table: m.table, pkKey: newKey, row: m.new });
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+
452
+ // -------------------------------------------------------------------------------------------
453
+ // The mirror applier (§4.4, P3) — the ONLY path replicated state takes into the engine
454
+ // -------------------------------------------------------------------------------------------
455
+
456
+ /** Apply row states through the mirror: diff against known state, emit the correct engine
457
+ * delta, commit as ONE `applyLocalReplica` batch, and update the mirror at the engine's
458
+ * post-commit anchor. Idempotent: matching state is a no-op. `staged` overlays the mirror
459
+ * WITHIN the batch, so two states for one pk compose (add + edit), never collide.
460
+ *
461
+ * `commit` names the commit being applied (absent for restore/promotion folds). It drives the
462
+ * §4.2 per-pk hold-back: while THIS tab has a newer uncommitted write to a pk, every other
463
+ * state for it — the echo of our own earlier write, or a foreign write the leader sequenced
464
+ * before ours — is stale relative to our tail; applying it would visibly rewind the row and
465
+ * snap it forward again (one full persist round-trip per keystroke on the draft-typing path).
466
+ * Skipped states skip engine AND mirror, which therefore stay in lockstep; the hold clears
467
+ * when the pending write's own commit arrives, and convergence is untouched (a foreign write
468
+ * sequenced AFTER ours applies normally once the hold is gone). */
469
+ private applyStates(states: RowState[], commit?: { origin: string; seq: number }): void {
470
+ const muts: Mutation[] = [];
471
+ const mirrorOps: Array<() => void> = [];
472
+ const staged = new Map<string, WireValue[] | null>(); // this batch's writes-so-far, by table\0pkKey
473
+ for (const s of states) {
474
+ const m = this.mirror.get(s.table);
475
+ if (!m) {
476
+ // A `local: "session"` table is OUTSIDE the plane (§5.4): drop silently — same posture
477
+ // as a stale-table snapshot record (a mixed-version peer mid-deploy is normal, §3.1).
478
+ if (this.sessionOnly.has(s.table)) continue;
479
+ if (s.row !== null) {
480
+ // Not a local table. Do NOT pre-filter: funnel it so `WasmBackend.writeLocal`'s M2
481
+ // guard rejects it loudly (P8) — the batch dies whole, the mirror is untouched.
482
+ muts.push({ op: "add", table: s.table, row: s.row });
483
+ }
484
+ continue;
485
+ }
486
+ const info = this.tables.get(s.table)!;
487
+ const key = `${s.table}\0${s.pkKey}`;
488
+ const pendingSeq = this.pendingByPk.get(key);
489
+ if (pendingSeq !== undefined) {
490
+ if (commit !== undefined && commit.origin === this.origin && commit.seq >= pendingSeq) {
491
+ this.pendingByPk.delete(key); // our latest write to this pk is the one committing
492
+ } else {
493
+ continue; // §4.2 hold-back: a newer own write is still uncommitted
494
+ }
495
+ }
496
+ const cur = staged.has(key) ? staged.get(key)! : (m.get(s.pkKey) ?? null);
497
+ if (s.row === null) {
498
+ if (cur) {
499
+ muts.push({ op: "remove", table: s.table, row: cur });
500
+ mirrorOps.push(() => m.delete(s.pkKey));
501
+ staged.set(key, null);
502
+ }
503
+ } else if (!validRow(s.row, info.width)) {
504
+ // Shape guard: a malformed replicated row must not reach the engine (wasm builds
505
+ // panic=abort) — same test as the snapshot/promotion ingress points, deliberately shared.
506
+ this.onError(new Error(`local persistence: dropped a malformed replicated row for "${s.table}"`));
507
+ } else if (!cur) {
508
+ const row = s.row;
509
+ muts.push({ op: "add", table: s.table, row });
510
+ mirrorOps.push(() => m.set(s.pkKey, row));
511
+ staged.set(key, row);
512
+ } else if (!rowsEqual(cur, s.row)) {
513
+ const row = s.row;
514
+ muts.push({ op: "edit", table: s.table, old: cur, new: row });
515
+ mirrorOps.push(() => m.set(s.pkKey, row));
516
+ staged.set(key, row);
517
+ }
518
+ }
519
+ // The mirror updates run at the engine's post-commit anchor — after `tx.commit()`, BEFORE
520
+ // subscriber delivery. A pre-commit rejection (M2) skips them with the engine untouched
521
+ // (loud, P8); a subscriber throwing during delivery re-raises AFTER them — either way
522
+ // mirror == engine. (Running them pre-delivery also means a subscriber that synchronously
523
+ // writeLocal's a row in this batch lands its fresher mirror entry AFTER ours — never under.)
524
+ if (muts.length) {
525
+ this.backend.applyLocalReplica(muts, () => {
526
+ for (const f of mirrorOps) f();
527
+ });
528
+ }
529
+ }
530
+
531
+ // -------------------------------------------------------------------------------------------
532
+ // The channel (§4.3)
533
+ // -------------------------------------------------------------------------------------------
534
+
535
+ private onMessage(msg: PersistMsg): void {
536
+ if (this.closed || !msg || typeof msg !== "object") return;
537
+ if (!this.live) {
538
+ this.bootBuffer.push(msg); // §4.6 step 1: buffer until the snapshot is in
539
+ return;
540
+ }
541
+ this.dispatch(msg);
542
+ }
543
+
544
+ private dispatch(msg: PersistMsg): void {
545
+ try {
546
+ if (msg.t === "op") {
547
+ // Only a LIVE leader sequences ops; mid-promotion (lock held, not announced) they are
548
+ // dropped — the sender re-sends on the `leader` announcement (§4.5).
549
+ if (this.leader && this.leaderLive) this.sequence(msg.origin, msg.seq, msg.batch);
550
+ } else if (msg.t === "commit") {
551
+ if (msg.epoch < this.highestEpoch) return; // stale epoch (P6)
552
+ this.highestEpoch = Math.max(this.highestEpoch, msg.epoch);
553
+ this.noteDurability(msg.batch, msg.p !== false); // P9 bookkeeping (promotion repairs it)
554
+ // Everyone applies, origin included (§4.2/P3) — modulo the per-pk hold-back, which skips
555
+ // states superseded by this tab's own uncommitted tail (see applyStates).
556
+ this.applyStates(msg.batch, { origin: msg.origin, seq: msg.seq });
557
+ if (msg.origin === this.origin) this.unacked.delete(msg.seq); // the ack (P4)
558
+ } else if (msg.t === "leader") {
559
+ this.highestEpoch = Math.max(this.highestEpoch, msg.epoch);
560
+ // P4: a new leader means our unacked ops may never have been persisted — re-send them
561
+ // all, in seq order; mirror application makes any duplicate a no-op (P3).
562
+ this.resendUnacked();
563
+ }
564
+ } catch (e) {
565
+ this.onError(asError(e));
566
+ }
567
+ }
568
+
569
+ // -------------------------------------------------------------------------------------------
570
+ // Leadership (§4.1, §4.5)
571
+ // -------------------------------------------------------------------------------------------
572
+
573
+ /** The lock callback: the returned promise HOLDS the lock until close() (§4.1). Promotion runs
574
+ * after our own restore completes; ops arriving meanwhile are dropped and re-sent on the
575
+ * announcement (§4.5). */
576
+ private onLockAcquired(): Promise<void> {
577
+ if (this.closed) {
578
+ // close() raced the grant (`abort()` no-ops once granted, and `releaseLock` was still
579
+ // null when close ran): resolve immediately, or the exclusive lock is held until the tab
580
+ // dies and every other tab for this user queues leaderless forever.
581
+ return Promise.resolve();
582
+ }
583
+ const held = new Promise<void>((resolve) => {
584
+ this.releaseLock = resolve;
585
+ });
586
+ void this.promote();
587
+ return held;
588
+ }
589
+
590
+ private async promote(): Promise<void> {
591
+ try {
592
+ await this.restored;
593
+ if (this.closed) return;
594
+ this.leader = true;
595
+
596
+ const pendingReset = await this.resetPending();
597
+ // P6: monotonic epochs — above both the persisted high-water and any announcement heard.
598
+ this.epoch = Math.max(this.epoch, this.highestEpoch) + 1;
599
+ this.highestEpoch = this.epoch;
600
+
601
+ if (this.db) {
602
+ if (pendingReset) {
603
+ // P7, deferred from boot (P2 — only the leader writes): clear + new hash, one txn.
604
+ await this.db.reset({ schemaHash: this.schemaHash, epoch: this.epoch });
605
+ if (this.closed) return;
606
+ this.notInIdb.clear(); // the store was just cleared; the seed below re-persists it all
607
+ // Adopt our follower-era ops FIRST (their per-pk holds clear in original order), then
608
+ // persist + announce the mirror itself: IDB was just cleared, so the "diff vs IDB"
609
+ // below is vacuous — this session's live rows are all the data there is.
610
+ this.adoptUnacked();
611
+ const seed = this.mirrorStates();
612
+ if (seed.length) this.sequence(this.origin, ++this.opSeq, seed);
613
+ } else {
614
+ await this.db.putMeta({ schemaHash: this.schemaHash, epoch: this.epoch });
615
+ if (this.closed) return;
616
+ await this.promotionDiff();
617
+ }
618
+ }
619
+ if (this.closed) return;
620
+
621
+ // Adopt our OWN unacked ops (we were a follower): sequence + persist them ourselves, in
622
+ // seq order. Anything the old leader did persist re-persists identically (idempotent).
623
+ this.adoptUnacked();
624
+
625
+ // Announce LAST (§4.5 step 3): followers now re-send their unacked ops to us.
626
+ this.leaderLive = true;
627
+ this.channel?.post({ t: "leader", epoch: this.epoch } satisfies PersistMsg);
628
+ } catch (e) {
629
+ if (this.closed) return;
630
+ this.onError(asError(e));
631
+ // Storage failed mid-promotion: stay leader (the lock is ours) in broadcast-only degrade —
632
+ // and STILL adopt our own unacked ops: no other tab can ever re-send them for us, and
633
+ // sequence() never throws (its persist failure is the P9 catch), so coherence survives
634
+ // even though this durability attempt didn't.
635
+ this.adoptUnacked();
636
+ this.leaderLive = true;
637
+ this.channel?.post({ t: "leader", epoch: this.epoch } satisfies PersistMsg);
638
+ }
639
+ }
640
+
641
+ /** Adopt our follower-era unacked ops as leader: sequence + persist them ourselves, in seq
642
+ * order (Map insertion order — seq is assigned at insert). Idempotent (the map is cleared). */
643
+ private adoptUnacked(): void {
644
+ for (const [seq, batch] of this.unacked) this.sequence(this.origin, seq, batch);
645
+ this.unacked.clear();
646
+ }
647
+
648
+ /** The P7 decision, made HERE — under the lock, where the read is authoritative (single
649
+ * writer: no one can move meta between this read and our reset/putMeta). Boot's verdict alone
650
+ * would be stale: an earlier same-version leader may have validly initialized or reset the
651
+ * store since (its unannounced tail must then be diffed in, not wiped). If this read FAILS,
652
+ * fall back to boot's verdict and fail CLOSED on anything but a clean boot match — the gate
653
+ * must never stamp the new hash over unverified rows (that legitimizes old-shape data
654
+ * forever; a wrongly-kept store self-heals via the promotion diff, a wrongly-stamped one
655
+ * never does). */
656
+ private async resetPending(): Promise<boolean> {
657
+ if (!this.db) return false;
658
+ try {
659
+ const meta = await this.db.getMeta();
660
+ if (meta?.schemaHash !== this.schemaHash) return true;
661
+ // Matches (boot may have seen `error`/a pre-initialization store): nothing to clear. If
662
+ // boot restored nothing, the promotion diff below folds every persisted row into the live
663
+ // engine (the same P5 path that heals a dead leader's gap). Re-adopt the persisted epoch
664
+ // high-water for P6 monotonicity.
665
+ this.epoch = Math.max(this.epoch, meta.epoch);
666
+ return false;
667
+ } catch (e) {
668
+ this.onError(asError(e));
669
+ return this.bootMeta !== "match";
670
+ }
671
+ }
672
+
673
+ /** P5: on promotion, IDB — not any tab's memory — is the durable source of truth. Re-read it and
674
+ * diff against the mirror; every difference (excluding keys our OWN unacked ops touch, which are
675
+ * newer by definition and re-sequenced right after) is a persisted-but-unannounced write from
676
+ * the dead leader's P1 gap: fold it into the mirror/engine and rebroadcast it under the new
677
+ * epoch. Records for tables no longer in the schema are swept from disk here (§3.1).
678
+ *
679
+ * The P9 REPAIR runs first: for keys whose last commit never landed in IDB (`p: false`), the
680
+ * mirror — not IDB — is the coherent truth; without the repair, the IDB gap would read as a
681
+ * persisted-but-unannounced REMOVE below, and a one-off storage error on the old leader would
682
+ * escalate into this promotion actively deleting committed, live rows from every tab. */
683
+ private async promotionDiff(): Promise<void> {
684
+ if (!this.db) return;
685
+ await this.repairDurability();
686
+ const persisted = await this.db.getAllRows();
687
+ if (this.closed) return;
688
+
689
+ const ownUnacked = new Set<string>();
690
+ for (const batch of this.unacked.values()) {
691
+ for (const s of batch) ownUnacked.add(`${s.table}\0${s.pkKey}`);
692
+ }
693
+
694
+ const diff: RowState[] = [];
695
+ const stale: Array<{ table: string; pkKey: string }> = [];
696
+ const seen = new Set<string>();
697
+ for (const rec of persisted) {
698
+ const info = this.tables.get(rec.table);
699
+ if (!info) {
700
+ stale.push({ table: rec.table, pkKey: rec.pkKey });
701
+ continue;
702
+ }
703
+ const key = `${rec.table}\0${rec.pkKey}`;
704
+ seen.add(key);
705
+ if (ownUnacked.has(key)) continue;
706
+ if (!validRow(rec.row, info.width)) {
707
+ this.onError(new Error(`local persistence: dropped a malformed row for "${rec.table}" (promotion diff)`));
708
+ continue;
709
+ }
710
+ const cur = this.mirror.get(rec.table)!.get(rec.pkKey);
711
+ if (!cur || !rowsEqual(cur, rec.row)) diff.push({ table: rec.table, pkKey: rec.pkKey, row: rec.row });
712
+ }
713
+ // Mirror rows ABSENT from IDB (and not ours in flight): a persisted-but-unannounced REMOVE.
714
+ // (Sound because the repair above already re-persisted every key IDB was known to be missing.)
715
+ for (const [table, m] of this.mirror) {
716
+ for (const [pkKey, row] of m) {
717
+ if (row === null) continue; // a pre-restore tombstone (never reaches promotion, but cheap)
718
+ const key = `${table}\0${pkKey}`;
719
+ if (!seen.has(key) && !ownUnacked.has(key)) diff.push({ table, pkKey, row: null });
720
+ }
721
+ }
722
+
723
+ if (diff.length) {
724
+ this.applyStates(diff); // fold into our own engine/mirror first (§4.5 step 2)
725
+ // Already persisted (it CAME from IDB) — broadcast straight, no re-persist needed.
726
+ this.channel?.post({
727
+ t: "commit",
728
+ epoch: this.epoch,
729
+ lseq: ++this.lseq,
730
+ origin: this.origin,
731
+ seq: 0,
732
+ batch: diff,
733
+ p: true,
734
+ } satisfies PersistMsg);
735
+ }
736
+ if (stale.length) await this.db.deleteRows(stale);
737
+ }
738
+
739
+ /** P9 repair: make IDB match the mirror for every key flagged un-persisted (`notInIdb`). The
740
+ * mirror holds the coherent truth for them — a present row re-puts, an absent one deletes.
741
+ * Idempotent; throws propagate to the caller (a failed repair keeps the flags for next time). */
742
+ private async repairDurability(): Promise<void> {
743
+ if (!this.db || this.notInIdb.size === 0) return;
744
+ const repair: RowState[] = [];
745
+ for (const key of this.notInIdb) {
746
+ const i = key.indexOf("\0");
747
+ const table = key.slice(0, i);
748
+ const pkKey = key.slice(i + 1);
749
+ const m = this.mirror.get(table);
750
+ if (!m) {
751
+ this.notInIdb.delete(key); // the table left the plane — its records get swept anyway
752
+ continue;
753
+ }
754
+ repair.push({ table, pkKey, row: m.get(pkKey) ?? null });
755
+ }
756
+ if (repair.length) await this.db.putBatch(repair);
757
+ for (const s of repair) this.notInIdb.delete(`${s.table}\0${s.pkKey}`);
758
+ }
759
+
760
+ /** P9 bookkeeping, updated from every commit (our own chain and the channel): `persisted: false`
761
+ * marks the batch's keys as missing from IDB until a later successful persist covers them. */
762
+ private noteDurability(batch: RowState[], persisted: boolean): void {
763
+ for (const s of batch) {
764
+ const key = `${s.table}\0${s.pkKey}`;
765
+ if (persisted) this.notInIdb.delete(key);
766
+ else this.notInIdb.add(key);
767
+ }
768
+ }
769
+
770
+ /** The full mirror as row state (the post-reset seed). Pre-restore tombstones are excluded —
771
+ * they mark "removed before the snapshot landed", not durable rows. */
772
+ private mirrorStates(): RowState[] {
773
+ const out: RowState[] = [];
774
+ for (const [table, m] of this.mirror) {
775
+ for (const [pkKey, row] of m) if (row !== null) out.push({ table, pkKey, row });
776
+ }
777
+ return out;
778
+ }
779
+
780
+ /** The leader's sequencer (§4.2): assign `(epoch, lseq)`, persist (ONE IDB txn), then broadcast
781
+ * the `commit` (P1) and apply it locally through the mirror (a no-op for our own writes, which
782
+ * the per-pk hold-back also shields from stale echoes). The chain serializes batches so IDB is
783
+ * a fold of commits in `(epoch, lseq)` order (P2).
784
+ *
785
+ * Deliberately NOT gated on `closed`: a queued step carries an already-committed write —
786
+ * cancelling it on an orderly close would silently drop durable data and its broadcast.
787
+ * close() drains this chain and only then releases the lock and the handles. */
788
+ private sequence(origin: string, seq: number, batch: RowState[]): void {
789
+ const epoch = this.epoch;
790
+ const lseq = ++this.lseq;
791
+ this.persistChain = this.persistChain.then(async () => {
792
+ let persisted = false;
793
+ try {
794
+ if (this.db) {
795
+ await this.repairDurability(); // P9 backlog first, so IDB folds in commit order
796
+ await this.db.putBatch(batch);
797
+ persisted = true;
798
+ }
799
+ } catch (e) {
800
+ this.onError(asError(e)); // durability degrades; coherence continues (P9)
801
+ }
802
+ this.noteDurability(batch, persisted);
803
+ this.channel?.post({ t: "commit", epoch, lseq, origin, seq, batch, p: persisted } satisfies PersistMsg);
804
+ try {
805
+ this.applyStates(batch, { origin, seq });
806
+ if (origin === this.origin) this.unacked.delete(seq);
807
+ } catch (e) {
808
+ this.onError(asError(e));
809
+ }
810
+ });
811
+ }
812
+
813
+ // -------------------------------------------------------------------------------------------
814
+ // Lifecycle
815
+ // -------------------------------------------------------------------------------------------
816
+
817
+ async flush(): Promise<void> {
818
+ // Best-effort final forward (§9), regardless of role: a mid-promotion leader still holds
819
+ // un-adopted follower-era ops, and for a plain follower this is the last chance for a live
820
+ // leader to persist them. A live leader's buffer is empty — the re-post is then a no-op.
821
+ this.resendUnacked();
822
+ if (this.leader) {
823
+ await this.persistChain;
824
+ try {
825
+ await this.repairDurability(); // last-chance durability for P9-degraded batches
826
+ } catch (e) {
827
+ this.onError(asError(e));
828
+ }
829
+ }
830
+ }
831
+
832
+ /** Re-post every unacked op, in seq order (Map insertion order — seq is assigned at insert). */
833
+ private resendUnacked(): void {
834
+ for (const [seq, batch] of this.unacked) {
835
+ this.channel?.post({ t: "op", origin: this.origin, seq, batch } satisfies PersistMsg);
836
+ }
837
+ }
838
+
839
+ close(): void {
840
+ if (this.closed) return;
841
+ this.closed = true;
842
+ this.lockAbort.abort(); // abandon a still-queued lock request
843
+ // A follower's final forward (the pagehide flush's guarantee, now on the orderly path too):
844
+ // re-post unacked ops while the channel is still open, so a live leader can persist them.
845
+ if (!this.leader) this.resendUnacked();
846
+ const finish = () => {
847
+ this.releaseLock?.(); // release held leadership (P10)
848
+ this.channel?.close();
849
+ this.channel = null;
850
+ this.db?.close();
851
+ this.db = null;
852
+ };
853
+ if (this.leader) {
854
+ // Drain, then release: queued persist steps carry already-COMMITTED writes (engine + every
855
+ // tab's view have them) — cancelling would silently lose them from IDB and from every
856
+ // other tab; and handing the lock over before the last putBatch lands would let the next
857
+ // leader diff against a torn store. The chain never rejects (every step catches).
858
+ void this.persistChain
859
+ .then(() => this.repairDurability())
860
+ .catch((e) => this.onError(asError(e)))
861
+ .then(finish);
862
+ } else {
863
+ finish();
864
+ }
865
+ }
866
+ }
867
+
868
+ // ---------------------------------------------------------------------------------------------
869
+ // Helpers
870
+ // ---------------------------------------------------------------------------------------------
871
+
872
+ /** `pkKey` (§3.1): JSON of the pk cells in schema pk order — a STRING deliberately (IDB keys
873
+ * admit neither `null` nor `boolean`, both legal rindle pk cells). Non-finite numbers get a
874
+ * NUL-prefixed tag: `JSON.stringify` folds NaN/±Infinity into `null`, which would collapse
875
+ * engine-distinct rows onto one storage/replication key (a remove of either would tombstone
876
+ * both). The NUL byte keeps a legitimate string cell from ever colliding with the tag. */
877
+ function pkKeyOf(row: WireValue[], pk: number[]): string {
878
+ return JSON.stringify(
879
+ pk.map((i) => {
880
+ const v = row[i] ?? null;
881
+ return typeof v === "number" && !Number.isFinite(v) ? `\u0000n:${String(v)}` : v;
882
+ }),
883
+ );
884
+ }
885
+
886
+ /** The replicated-row shape guard, shared by ALL three ingress points (snapshot restore, live
887
+ * commits, the promotion diff) so they can never drift: a malformed row must not reach the
888
+ * engine (wasm builds panic=abort). */
889
+ function validRow(row: unknown, width: number): row is WireValue[] {
890
+ return Array.isArray(row) && row.length === width;
891
+ }
892
+
893
+ function asError(e: unknown): Error {
894
+ return e instanceof Error ? e : new Error(String(e));
895
+ }
896
+
897
+ function mintOrigin(): string {
898
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
899
+ if (c?.randomUUID) return c.randomUUID();
900
+ return `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
901
+ }
902
+
903
+ // ---------------------------------------------------------------------------------------------
904
+ // The default (real-browser) environment — structural typing over the globals (no DOM lib here)
905
+ // ---------------------------------------------------------------------------------------------
906
+
907
+ // The slivers of the IDB/locks/BroadcastChannel surfaces this module touches.
908
+ interface IdbRequestLike<T = unknown> {
909
+ result: T;
910
+ error?: unknown;
911
+ onsuccess: ((ev: unknown) => void) | null;
912
+ onerror: ((ev: unknown) => void) | null;
913
+ }
914
+ interface IdbOpenRequestLike extends IdbRequestLike<IdbDatabaseLike> {
915
+ onupgradeneeded: ((ev: unknown) => void) | null;
916
+ onblocked: ((ev: unknown) => void) | null;
917
+ }
918
+ interface IdbObjectStoreLike {
919
+ put(value: unknown, key?: unknown): IdbRequestLike;
920
+ delete(key: unknown): IdbRequestLike;
921
+ get(key: unknown): IdbRequestLike;
922
+ getAll(): IdbRequestLike<unknown[]>;
923
+ clear(): IdbRequestLike;
924
+ }
925
+ interface IdbTransactionLike {
926
+ objectStore(name: string): IdbObjectStoreLike;
927
+ oncomplete: ((ev: unknown) => void) | null;
928
+ onerror: ((ev: unknown) => void) | null;
929
+ onabort: ((ev: unknown) => void) | null;
930
+ error?: unknown;
931
+ }
932
+ interface IdbDatabaseLike {
933
+ transaction(stores: string[], mode: "readonly" | "readwrite"): IdbTransactionLike;
934
+ createObjectStore(name: string, opts?: { keyPath?: string | string[] }): unknown;
935
+ objectStoreNames: { contains(name: string): boolean };
936
+ onversionchange: ((ev: unknown) => void) | null;
937
+ close(): void;
938
+ }
939
+ interface IdbFactoryLike {
940
+ open(name: string, version: number): IdbOpenRequestLike;
941
+ deleteDatabase(name: string): IdbRequestLike;
942
+ }
943
+ interface BroadcastChannelLike {
944
+ postMessage(msg: unknown): void;
945
+ onmessage: ((ev: { data: unknown }) => void) | null;
946
+ close(): void;
947
+ }
948
+ interface LockManagerLike {
949
+ request(name: string, opts: { mode: "exclusive"; signal?: AbortSignal }, cb: () => Promise<void>): Promise<unknown>;
950
+ }
951
+
952
+ const ROWS = "rows";
953
+ const META = "meta";
954
+ const META_KEY = "meta";
955
+
956
+ /** Wrap one IDB transaction's completion as a promise (resolution == txn durably committed —
957
+ * the exact anchor P1 needs). */
958
+ function txnDone(txn: IdbTransactionLike): Promise<void> {
959
+ return new Promise((resolve, reject) => {
960
+ txn.oncomplete = () => resolve();
961
+ txn.onerror = () => reject(asError(txn.error ?? new Error("IndexedDB transaction failed")));
962
+ txn.onabort = () => reject(asError(txn.error ?? new Error("IndexedDB transaction aborted")));
963
+ });
964
+ }
965
+
966
+ function reqDone<T>(req: IdbRequestLike<T>): Promise<T> {
967
+ return new Promise((resolve, reject) => {
968
+ req.onsuccess = () => resolve(req.result);
969
+ req.onerror = () => reject(asError(req.error ?? new Error("IndexedDB request failed")));
970
+ });
971
+ }
972
+
973
+ class IdbPersistDb implements PersistDb {
974
+ private readonly db: IdbDatabaseLike;
975
+
976
+ constructor(db: IdbDatabaseLike) {
977
+ this.db = db;
978
+ }
979
+
980
+ async getMeta(): Promise<PersistMeta | undefined> {
981
+ const txn = this.db.transaction([META], "readonly");
982
+ const out = await reqDone(txn.objectStore(META).get(META_KEY));
983
+ return out as PersistMeta | undefined;
984
+ }
985
+
986
+ putMeta(meta: PersistMeta): Promise<void> {
987
+ const txn = this.db.transaction([META], "readwrite");
988
+ txn.objectStore(META).put(meta, META_KEY);
989
+ return txnDone(txn);
990
+ }
991
+
992
+ async getAllRows(): Promise<StoredRow[]> {
993
+ const txn = this.db.transaction([ROWS], "readonly");
994
+ const out = await reqDone(txn.objectStore(ROWS).getAll());
995
+ return out as StoredRow[];
996
+ }
997
+
998
+ putBatch(batch: RowState[]): Promise<void> {
999
+ const txn = this.db.transaction([ROWS], "readwrite");
1000
+ const store = txn.objectStore(ROWS);
1001
+ for (const s of batch) {
1002
+ if (s.row === null) store.delete([s.table, s.pkKey]);
1003
+ else store.put({ table: s.table, pkKey: s.pkKey, row: s.row } satisfies StoredRow);
1004
+ }
1005
+ return txnDone(txn);
1006
+ }
1007
+
1008
+ reset(meta: PersistMeta): Promise<void> {
1009
+ const txn = this.db.transaction([ROWS, META], "readwrite");
1010
+ txn.objectStore(ROWS).clear();
1011
+ txn.objectStore(META).put(meta, META_KEY);
1012
+ return txnDone(txn);
1013
+ }
1014
+
1015
+ deleteRows(keys: Array<{ table: string; pkKey: string }>): Promise<void> {
1016
+ const txn = this.db.transaction([ROWS], "readwrite");
1017
+ const store = txn.objectStore(ROWS);
1018
+ for (const k of keys) store.delete([k.table, k.pkKey]);
1019
+ return txnDone(txn);
1020
+ }
1021
+
1022
+ close(): void {
1023
+ this.db.close();
1024
+ }
1025
+ }
1026
+
1027
+ /** How long a BLOCKED IDB open may park before the layer degrades to broadcast-only: a pending
1028
+ * `deleteDatabase` (a sibling tab's logout) queues every later open behind it, and an old-version
1029
+ * connection with no `versionchange` handler can hold that queue indefinitely — client
1030
+ * construction must never hang on it. */
1031
+ const OPEN_BLOCKED_TIMEOUT_MS = 2000;
1032
+
1033
+ /** The real-browser environment: IndexedDB + BroadcastChannel + `navigator.locks`, each reached
1034
+ * structurally and each degrading gracefully when absent (§3.2). */
1035
+ export function defaultEnv(): PersistEnv {
1036
+ const g = globalThis as unknown as {
1037
+ indexedDB?: IdbFactoryLike;
1038
+ BroadcastChannel?: new (name: string) => BroadcastChannelLike;
1039
+ navigator?: { locks?: LockManagerLike; storage?: { persist?: () => Promise<boolean> } };
1040
+ };
1041
+ const locks = g.navigator?.locks;
1042
+ return {
1043
+ // Multi-context runtime with no exclusive lock (Firefox <96, Safari 15.1–15.3, Node): the
1044
+ // solo-mode fallback below would make EVERY context a leader — the layer must run inert.
1045
+ leaderElectionUnavailable: !locks && typeof g.BroadcastChannel === "function",
1046
+ openDatabase(name: string): Promise<PersistDb | null> {
1047
+ const idb = g.indexedDB;
1048
+ if (!idb) return Promise.resolve(null);
1049
+ return new Promise((resolve) => {
1050
+ let settled = false;
1051
+ const settle = (db: PersistDb | null) => {
1052
+ if (settled) return;
1053
+ settled = true;
1054
+ resolve(db);
1055
+ };
1056
+ let req: IdbOpenRequestLike;
1057
+ try {
1058
+ req = idb.open(name, 1);
1059
+ } catch {
1060
+ resolve(null); // some privacy modes throw synchronously — degrade (§3.2)
1061
+ return;
1062
+ }
1063
+ req.onupgradeneeded = () => {
1064
+ // Version 1 forever (§3.1): a fixed two-store shape, so a schema's table-set change
1065
+ // never needs an IDB version bump.
1066
+ const db = req.result;
1067
+ if (!db.objectStoreNames.contains(ROWS)) db.createObjectStore(ROWS, { keyPath: ["table", "pkKey"] });
1068
+ if (!db.objectStoreNames.contains(META)) db.createObjectStore(META);
1069
+ };
1070
+ req.onsuccess = () => {
1071
+ const db = req.result;
1072
+ // A sibling tab's deleteDatabase (logout) fires `versionchange` at every open
1073
+ // connection and then WAITS for them all to close: release ours so the delete (and
1074
+ // any open queued behind it) can proceed — this session degrades (P9), the logout
1075
+ // completes. Without this, one live tab wedges every other tab's logout forever.
1076
+ db.onversionchange = () => db.close();
1077
+ if (settled) {
1078
+ db.close(); // the blocked-open timeout already degraded us — don't hold a handle
1079
+ return;
1080
+ }
1081
+ settle(new IdbPersistDb(db));
1082
+ };
1083
+ req.onerror = () => settle(null); // unavailable → broadcast-only degrade (§3.2)
1084
+ req.onblocked = () => {
1085
+ // Parked behind a pending delete (or an old-version connection): degrade after a
1086
+ // bounded wait rather than hanging client construction — never forever.
1087
+ setTimeout(() => settle(null), OPEN_BLOCKED_TIMEOUT_MS);
1088
+ };
1089
+ });
1090
+ },
1091
+ deleteDatabase(name: string): Promise<void> {
1092
+ const idb = g.indexedDB;
1093
+ if (!idb) return Promise.resolve();
1094
+ return reqDone(idb.deleteDatabase(name)).then(() => undefined);
1095
+ },
1096
+ createChannel(name: string): PersistChannel | null {
1097
+ const BC = g.BroadcastChannel;
1098
+ if (!BC) return null;
1099
+ const ch = new BC(name);
1100
+ return {
1101
+ post: (msg) => {
1102
+ try {
1103
+ ch.postMessage(msg);
1104
+ } catch {
1105
+ /* a closed/failed channel loses coherence, never the write path (P9) */
1106
+ }
1107
+ },
1108
+ onMessage: (handler) => {
1109
+ ch.onmessage = (ev) => handler(ev.data);
1110
+ },
1111
+ close: () => ch.close(),
1112
+ };
1113
+ },
1114
+ requestLock(name: string, signal: AbortSignal, onAcquired: () => Promise<void>): void {
1115
+ if (!locks) {
1116
+ // No Web Locks AND no BroadcastChannel (`leaderElectionUnavailable` gates the layer off
1117
+ // otherwise): a provably single-context runtime — solo mode, safe by construction.
1118
+ void onAcquired();
1119
+ return;
1120
+ }
1121
+ void locks.request(name, { mode: "exclusive", signal }, onAcquired).catch(() => {
1122
+ /* an aborted queued request (close before acquisition) — expected */
1123
+ });
1124
+ },
1125
+ requestPersistentStorage(): void {
1126
+ void g.navigator?.storage?.persist?.()?.catch?.(() => {});
1127
+ },
1128
+ };
1129
+ }