@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.
- package/README.md +30 -61
- package/dist/backend.d.ts +29 -16
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +77 -21
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts +9 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +22 -5
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/local-persist.d.ts +102 -0
- package/dist/local-persist.d.ts.map +1 -0
- package/dist/local-persist.js +957 -0
- package/dist/local-persist.js.map +1 -0
- package/package.json +6 -5
- package/src/backend.ts +93 -30
- package/src/client.ts +31 -5
- package/src/index.ts +15 -0
- package/src/local-persist.ts +1129 -0
|
@@ -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
|