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