@taladb/web 0.10.2 → 0.11.0
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/LICENSE-MIT +21 -0
- package/package.json +9 -3
- package/pkg/LICENSE-APACHE +202 -0
- package/pkg/LICENSE-MIT +21 -0
- package/pkg/package.json +3 -3
- package/pkg/taladb_web.d.ts +19 -115
- package/pkg/taladb_web.js +45 -316
- package/pkg/taladb_web_bg.wasm +0 -0
- package/pkg/taladb_web_bg.wasm.d.ts +3 -14
- package/worker/taladb.worker.js +320 -141
- /package/{LICENSE → LICENSE-APACHE} +0 -0
package/worker/taladb.worker.js
CHANGED
|
@@ -53,9 +53,6 @@
|
|
|
53
53
|
* hybridSearch { collection, textField, text, vectorField, vectorJson, topK, filterJson?, optionsJson? }
|
|
54
54
|
* listCollections {} → JSON string[]
|
|
55
55
|
* compact {} → null
|
|
56
|
-
* compactTombstones { collection, beforeMs } → number pruned
|
|
57
|
-
* exportChangeset { collectionsJson, sinceMs? } → JSON changeset string
|
|
58
|
-
* importChangeset { changesetJson } → number of applied changes
|
|
59
56
|
* close {}
|
|
60
57
|
*
|
|
61
58
|
* Multi-tab live queries (BroadcastChannel)
|
|
@@ -65,15 +62,32 @@
|
|
|
65
62
|
* `"taladb:<dbName>"`. Other tabs listening on the same channel re-trigger
|
|
66
63
|
* their active `subscribe()` pollers immediately, bypassing the 300 ms tick.
|
|
67
64
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
65
|
+
* Cross-tab write forwarding
|
|
66
|
+
* --------------------------
|
|
67
|
+
* A tab that cannot take the OPFS lock runs an in-memory database and does NOT
|
|
68
|
+
* publish the IndexedDB snapshot, so it has no durable storage of its own.
|
|
69
|
+
* After every write it forwards the change on the BroadcastChannel as
|
|
70
|
+
* `{ type: 'taladb:tab-write', … }`; the tab holding the lock applies it and
|
|
71
|
+
* runs the normal onWriteCommitted() path, so every tab is notified and the
|
|
72
|
+
* OPFS file stays authoritative.
|
|
73
|
+
*
|
|
74
|
+
* Inserts forward whole documents with their `_id`s (upserted by id, so an id
|
|
75
|
+
* the application already holds stays valid); filter-based updates and deletes
|
|
76
|
+
* forward the filter and update, re-evaluated by the primary against
|
|
77
|
+
* authoritative data. Ordering is arrival order at the primary — two tabs on
|
|
78
|
+
* one device share a clock, so there is no skew to arbitrate.
|
|
79
|
+
*
|
|
80
|
+
* The primary acknowledges each forwarded write. Unacknowledged writes are kept
|
|
81
|
+
* so that a tab promoted to owner (below) can replay exactly the ones that were
|
|
82
|
+
* never persisted.
|
|
83
|
+
*
|
|
84
|
+
* Lock takeover
|
|
85
|
+
* -------------
|
|
86
|
+
* A tab that loses the OPFS lock queues for it in the background. When the
|
|
87
|
+
* holder closes its tab or calls `db.close()`, the lock passes to a waiting tab,
|
|
88
|
+
* which opens the OPFS file, adopts it as authoritative, and replays its own
|
|
89
|
+
* unacknowledged writes. Without this, closing the tab that owned the file left
|
|
90
|
+
* every other tab writing into memory that nothing would ever persist.
|
|
77
91
|
*
|
|
78
92
|
* IndexedDB fallback (no OPFS)
|
|
79
93
|
* ----------------------------
|
|
@@ -117,6 +131,54 @@ let pendingSnapshotResolve = null;
|
|
|
117
131
|
*/
|
|
118
132
|
const initPromises = new Map();
|
|
119
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Identifies this worker on the BroadcastChannel, so a forwarded write's
|
|
136
|
+
* acknowledgement can be routed back to the tab that sent it.
|
|
137
|
+
*/
|
|
138
|
+
const TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Forwarded writes this tab has sent but the primary has not acknowledged.
|
|
142
|
+
*
|
|
143
|
+
* A transient tab's writes live only in its own RAM until the primary applies
|
|
144
|
+
* them. If the primary goes away — the tab holding the OPFS lock is closed —
|
|
145
|
+
* nothing acknowledges them, and they used to be lost outright. Keeping them
|
|
146
|
+
* here lets `promoteToPrimary` replay exactly the writes that never landed,
|
|
147
|
+
* without replaying any that did (a replayed `$inc` would double-count).
|
|
148
|
+
* @type {Map<number, object>}
|
|
149
|
+
*/
|
|
150
|
+
const unackedWrites = new Map();
|
|
151
|
+
let writeSeq = 0;
|
|
152
|
+
|
|
153
|
+
/** Bound on `unackedWrites`, so an absent primary cannot grow it without limit. */
|
|
154
|
+
const MAX_UNACKED = 1000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Per-collection offsets keeping `writeGeneration` monotonic across the
|
|
158
|
+
* database instance swaps a snapshot reload performs.
|
|
159
|
+
*
|
|
160
|
+
* The generation is an in-memory counter owned by the `Database` instance, so
|
|
161
|
+
* reloading a snapshot resets it to zero. Live-query pollers treat "same
|
|
162
|
+
* generation as last tick" as "nothing changed and the query can be skipped" —
|
|
163
|
+
* and a reload only ever happens *because* another tab wrote. Returning the raw
|
|
164
|
+
* counter therefore made a fallback tab's live queries go permanently silent:
|
|
165
|
+
* every reload put the generation back to the value the poller had already seen.
|
|
166
|
+
* @type {Map<string, number>}
|
|
167
|
+
*/
|
|
168
|
+
const generationBase = new Map();
|
|
169
|
+
/** Last raw engine generation observed per collection, for the offset above. */
|
|
170
|
+
const generationRaw = new Map();
|
|
171
|
+
|
|
172
|
+
/** Fold the outgoing instance's generations into the offsets, before a swap. */
|
|
173
|
+
function carryGenerationsForward() {
|
|
174
|
+
for (const [collection, raw] of generationRaw) {
|
|
175
|
+
// `+ 1` so the next reading differs even when the new instance starts at
|
|
176
|
+
// the same raw value the old one ended on.
|
|
177
|
+
generationBase.set(collection, (generationBase.get(collection) ?? 0) + raw + 1);
|
|
178
|
+
}
|
|
179
|
+
generationRaw.clear();
|
|
180
|
+
}
|
|
181
|
+
|
|
120
182
|
/**
|
|
121
183
|
* WASM fetch + compile, started at module scope rather than inside `doInit`.
|
|
122
184
|
*
|
|
@@ -154,15 +216,6 @@ const warn = isDev ? console.warn.bind(console, '[TalaDB Worker]') : () => {};
|
|
|
154
216
|
*/
|
|
155
217
|
const WORKER_ORIGIN = typeof location !== 'undefined' ? location.origin : null;
|
|
156
218
|
|
|
157
|
-
/**
|
|
158
|
-
* Per-worker-instance random token. Included in every `taladb:secondary-write`
|
|
159
|
-
* BroadcastChannel message so the primary tab can reject unauthenticated
|
|
160
|
-
* injections that omit a token.
|
|
161
|
-
*/
|
|
162
|
-
const SESSION_TOKEN = (typeof self.crypto !== 'undefined' && typeof self.crypto.randomUUID === 'function')
|
|
163
|
-
? self.crypto.randomUUID()
|
|
164
|
-
: Array.from(self.crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
|
|
165
|
-
|
|
166
219
|
/**
|
|
167
220
|
* Resolving this releases the Web Lock and closes the sync handle.
|
|
168
221
|
* Set inside doInit; called by the 'close' op or when the worker terminates.
|
|
@@ -198,12 +251,6 @@ let snapshotWriter = false;
|
|
|
198
251
|
*/
|
|
199
252
|
let encrypted = false;
|
|
200
253
|
|
|
201
|
-
/**
|
|
202
|
-
* Timestamp (ms) of the last changeset we exported and broadcast to the
|
|
203
|
-
* primary tab. Used as `sinceMs` for the next export so we only send the
|
|
204
|
-
* delta, not the entire database on every write.
|
|
205
|
-
* @type {number}
|
|
206
|
-
*/
|
|
207
254
|
// ---------------------------------------------------------------------------
|
|
208
255
|
// IndexedDB helpers (used only when OPFS is unavailable)
|
|
209
256
|
// ---------------------------------------------------------------------------
|
|
@@ -339,31 +386,116 @@ function applyDurability(inst) {
|
|
|
339
386
|
}
|
|
340
387
|
|
|
341
388
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
389
|
+
* True when this tab writes into an in-memory database that nothing else
|
|
390
|
+
* persists — i.e. another tab holds the OPFS lock (`snapshotWriter === false`).
|
|
344
391
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
392
|
+
* Such a tab has no durable storage of its own: it does not own the OPFS file
|
|
393
|
+
* and does not publish the IndexedDB snapshot. Every write it makes must be
|
|
394
|
+
* forwarded to the tab that does, or it exists only in RAM and is discarded on
|
|
395
|
+
* the next snapshot reload.
|
|
349
396
|
*/
|
|
350
|
-
function
|
|
351
|
-
|
|
397
|
+
function isTransientTab() {
|
|
398
|
+
return idbFallback && !snapshotWriter;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Forward a committed write to the tab holding the OPFS lock.
|
|
403
|
+
*
|
|
404
|
+
* Inserts travel as whole documents **with their `_id`s** so the primary can
|
|
405
|
+
* preserve the ids the application is already holding. They remain inserts,
|
|
406
|
+
* not upserts: a caller-supplied id collision is rejected at the authoritative
|
|
407
|
+
* database instead of overwriting its document. Filter-based updates and
|
|
408
|
+
* deletes travel as the filter and update themselves, and the primary
|
|
409
|
+
* re-evaluates them against authoritative data, which is more accurate than
|
|
410
|
+
* resolving ids here against a possibly-stale in-memory copy.
|
|
411
|
+
*
|
|
412
|
+
* Ordering is arrival order at the primary. Unlike the changeset merge this
|
|
413
|
+
* replaces, there is no timestamp comparison — two tabs on one device share a
|
|
414
|
+
* clock and an origin, so there is no skew for Last-Write-Wins to arbitrate.
|
|
415
|
+
*/
|
|
416
|
+
function forwardWriteToPrimary(op, args, result) {
|
|
417
|
+
if (!isTransientTab() || !broadcastChannel || !db) return;
|
|
352
418
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
419
|
+
let payload = null;
|
|
420
|
+
if (op === 'insert' || op === 'insertMany') {
|
|
421
|
+
// Read the documents back so they carry the `_id`s just assigned.
|
|
422
|
+
// `insert` answers with a bare ULID, `insertMany` with a JSON array —
|
|
423
|
+
// parsing the bare id as JSON threw, and the catch below turned every
|
|
424
|
+
// single-document insert in a non-primary tab into a silent data loss.
|
|
425
|
+
const ids = op === 'insert' ? [result] : JSON.parse(result);
|
|
426
|
+
const docs = ids
|
|
427
|
+
.map((id) => JSON.parse(db.findOne(args.collection, JSON.stringify({ _id: id }))))
|
|
428
|
+
.filter((d) => d !== null);
|
|
429
|
+
if (docs.length === 0) return;
|
|
430
|
+
// Preserve create semantics at the authoritative database. An upsert here
|
|
431
|
+
// lets two tabs inserting the same caller-supplied id overwrite each other,
|
|
432
|
+
// even though Collection::insert promises DuplicateId and never replace.
|
|
433
|
+
payload = { kind: 'insert', collection: args.collection, docsJson: JSON.stringify(docs) };
|
|
434
|
+
} else {
|
|
435
|
+
payload = {
|
|
436
|
+
kind: 'replay',
|
|
437
|
+
op,
|
|
438
|
+
collection: args.collection,
|
|
439
|
+
filterJson: args.filterJson,
|
|
440
|
+
updateJson: args.updateJson,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// Held until the primary acknowledges, so `promoteToPrimary` can replay
|
|
444
|
+
// whatever never landed. Dropping the oldest keeps a tab that has been
|
|
445
|
+
// running without a primary from growing this without bound.
|
|
446
|
+
const seq = ++writeSeq;
|
|
447
|
+
if (unackedWrites.size >= MAX_UNACKED) {
|
|
448
|
+
const oldest = unackedWrites.keys().next().value;
|
|
449
|
+
unackedWrites.delete(oldest);
|
|
450
|
+
warn('Unacknowledged write buffer is full — the oldest forwarded write was dropped');
|
|
451
|
+
}
|
|
452
|
+
unackedWrites.set(seq, payload);
|
|
453
|
+
broadcastChannel.postMessage({ type: 'taladb:tab-write', origin: TAB_ID, seq, ...payload });
|
|
365
454
|
} catch (err) {
|
|
366
|
-
|
|
455
|
+
// A failed forward must not fail the caller's write — it already committed
|
|
456
|
+
// locally. Surface it loudly, because the write is now at risk of loss.
|
|
457
|
+
warn('Failed to forward write to the primary tab — it may not be persisted:', err);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Apply one forwarded write payload to the local (authoritative) database.
|
|
463
|
+
* Returns the number of documents affected.
|
|
464
|
+
*/
|
|
465
|
+
function applyForwardedWrite(payload) {
|
|
466
|
+
if (payload.kind === 'insert') {
|
|
467
|
+
const docs = JSON.parse(payload.docsJson);
|
|
468
|
+
// A collision is a terminal evaluation, not a transient forwarding error.
|
|
469
|
+
// Return zero so the primary acknowledges it and the secondary cannot later
|
|
470
|
+
// replay/resurrect its rejected document after the authoritative one is
|
|
471
|
+
// deleted. The worker handles BroadcastChannel messages serially, so this
|
|
472
|
+
// preflight and the synchronous insert below cannot race another message.
|
|
473
|
+
for (const doc of docs) {
|
|
474
|
+
if (typeof doc?._id !== 'string') continue;
|
|
475
|
+
const existing = db.findOne(
|
|
476
|
+
payload.collection,
|
|
477
|
+
JSON.stringify({ _id: doc._id }),
|
|
478
|
+
);
|
|
479
|
+
if (JSON.parse(existing) !== null) {
|
|
480
|
+
warn(`Rejected forwarded insert with duplicate _id ${doc._id}`);
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return JSON.parse(db.insertMany(payload.collection, payload.docsJson)).length;
|
|
485
|
+
}
|
|
486
|
+
if (payload.kind !== 'replay') return 0;
|
|
487
|
+
switch (payload.op) {
|
|
488
|
+
case 'updateOne':
|
|
489
|
+
return db.updateOne(payload.collection, payload.filterJson, payload.updateJson) ? 1 : 0;
|
|
490
|
+
case 'updateMany':
|
|
491
|
+
return db.updateMany(payload.collection, payload.filterJson, payload.updateJson);
|
|
492
|
+
case 'deleteOne':
|
|
493
|
+
return db.deleteOne(payload.collection, payload.filterJson) ? 1 : 0;
|
|
494
|
+
case 'deleteMany':
|
|
495
|
+
return db.deleteMany(payload.collection, payload.filterJson);
|
|
496
|
+
default:
|
|
497
|
+
warn('Unknown forwarded write op:', payload.op);
|
|
498
|
+
return 0;
|
|
367
499
|
}
|
|
368
500
|
}
|
|
369
501
|
|
|
@@ -371,11 +503,9 @@ function pushChangesetToPrimary() {
|
|
|
371
503
|
* Notify sibling tabs of a write and schedule IDB persistence.
|
|
372
504
|
* Must be called after every mutating op.
|
|
373
505
|
*/
|
|
374
|
-
function onWriteCommitted() {
|
|
506
|
+
function onWriteCommitted(op, args, result) {
|
|
375
507
|
broadcastChannel?.postMessage('taladb:changed');
|
|
376
|
-
|
|
377
|
-
// merged into the authoritative database file.
|
|
378
|
-
pushChangesetToPrimary();
|
|
508
|
+
if (op) forwardWriteToPrimary(op, args, result);
|
|
379
509
|
// Debounced IDB flush — keeps other tabs' fallback instances in sync via
|
|
380
510
|
// BroadcastChannel + snapshotDirty reload without writing to IDB on every op.
|
|
381
511
|
if (snapshotWriter) scheduleSnapshot();
|
|
@@ -413,6 +543,9 @@ async function dispatch(op, args) {
|
|
|
413
543
|
try {
|
|
414
544
|
const fresh = await idbLoadSnapshot(activeDbName);
|
|
415
545
|
if (fresh) {
|
|
546
|
+
// The new instance's generation counter starts from zero; fold the old
|
|
547
|
+
// one's into the offsets so live-query pollers still see a change.
|
|
548
|
+
carryGenerationsForward();
|
|
416
549
|
db = activeConfigJson
|
|
417
550
|
? WorkerDB.openWithConfigAndSnapshot(fresh, activeConfigJson)
|
|
418
551
|
: WorkerDB.openWithSnapshot(fresh);
|
|
@@ -444,25 +577,13 @@ async function dispatch(op, args) {
|
|
|
444
577
|
switch (op) {
|
|
445
578
|
case 'insert': {
|
|
446
579
|
const result = db.insert(args.collection, args.docJson);
|
|
447
|
-
onWriteCommitted();
|
|
580
|
+
onWriteCommitted('insert', args, result);
|
|
448
581
|
return result;
|
|
449
582
|
}
|
|
450
583
|
|
|
451
584
|
case 'insertMany': {
|
|
452
585
|
const result = db.insertMany(args.collection, args.docsJson);
|
|
453
|
-
onWriteCommitted();
|
|
454
|
-
return result;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
case 'replaceManyWithIds': {
|
|
458
|
-
const result = db.replaceManyWithIds(args.collection, args.docsJson, args.origin);
|
|
459
|
-
onWriteCommitted();
|
|
460
|
-
return result;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
case 'deleteManyWithIds': {
|
|
464
|
-
const result = db.deleteManyWithIds(args.collection, args.idsJson, args.origin);
|
|
465
|
-
onWriteCommitted();
|
|
586
|
+
onWriteCommitted('insertMany', args, result);
|
|
466
587
|
return result;
|
|
467
588
|
}
|
|
468
589
|
|
|
@@ -474,25 +595,25 @@ async function dispatch(op, args) {
|
|
|
474
595
|
|
|
475
596
|
case 'updateOne': {
|
|
476
597
|
const result = db.updateOne(args.collection, args.filterJson, args.updateJson);
|
|
477
|
-
onWriteCommitted();
|
|
598
|
+
onWriteCommitted('updateOne', args, result);
|
|
478
599
|
return result;
|
|
479
600
|
}
|
|
480
601
|
|
|
481
602
|
case 'updateMany': {
|
|
482
603
|
const result = db.updateMany(args.collection, args.filterJson, args.updateJson);
|
|
483
|
-
onWriteCommitted();
|
|
604
|
+
onWriteCommitted('updateMany', args, result);
|
|
484
605
|
return result;
|
|
485
606
|
}
|
|
486
607
|
|
|
487
608
|
case 'deleteOne': {
|
|
488
609
|
const result = db.deleteOne(args.collection, args.filterJson);
|
|
489
|
-
onWriteCommitted();
|
|
610
|
+
onWriteCommitted('deleteOne', args, result);
|
|
490
611
|
return result;
|
|
491
612
|
}
|
|
492
613
|
|
|
493
614
|
case 'deleteMany': {
|
|
494
615
|
const result = db.deleteMany(args.collection, args.filterJson);
|
|
495
|
-
onWriteCommitted();
|
|
616
|
+
onWriteCommitted('deleteMany', args, result);
|
|
496
617
|
return result;
|
|
497
618
|
}
|
|
498
619
|
|
|
@@ -500,8 +621,11 @@ async function dispatch(op, args) {
|
|
|
500
621
|
return db.count(args.collection, args.filterJson ?? 'null');
|
|
501
622
|
|
|
502
623
|
// Cheap change-detection for subscribe(): one integer, no query.
|
|
503
|
-
case 'writeGeneration':
|
|
504
|
-
|
|
624
|
+
case 'writeGeneration': {
|
|
625
|
+
const raw = db.writeGeneration(args.collection);
|
|
626
|
+
generationRaw.set(args.collection, raw);
|
|
627
|
+
return (generationBase.get(args.collection) ?? 0) + raw;
|
|
628
|
+
}
|
|
505
629
|
|
|
506
630
|
case 'aggregate':
|
|
507
631
|
return db.aggregate(args.collection, args.pipelineJson ?? '[]');
|
|
@@ -599,51 +723,11 @@ async function dispatch(op, args) {
|
|
|
599
723
|
await flushSnapshot();
|
|
600
724
|
return null;
|
|
601
725
|
|
|
602
|
-
case '
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
while (db.syncPending() > 0 && Date.now() < deadline) {
|
|
608
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
609
|
-
}
|
|
610
|
-
return db.syncPending() === 0;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
case 'compactTombstones':
|
|
614
|
-
// Prune tombstones older than beforeMs from a collection.
|
|
615
|
-
// Returns the count of tombstones removed.
|
|
616
|
-
return db.compactTombstones(args.collection, args.beforeMs ?? 0);
|
|
617
|
-
|
|
618
|
-
case 'exportChangeset':
|
|
619
|
-
// Export a LWW changeset for the given collections since sinceMs.
|
|
620
|
-
// Returns a JSON string the caller can POST to a sync server.
|
|
621
|
-
return db.exportChangeset(args.collectionsJson, args.sinceMs ?? 0);
|
|
622
|
-
|
|
623
|
-
case 'importChangeset': {
|
|
624
|
-
// Apply a remote changeset (JSON string from sync server) using LWW.
|
|
625
|
-
// Triggers onWriteCommitted so multi-tab peers get notified.
|
|
626
|
-
const applied = db.importChangeset(args.changesetJson);
|
|
627
|
-
if (applied > 0) onWriteCommitted();
|
|
628
|
-
return applied;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
case 'importChangesetValidated': {
|
|
632
|
-
// Tolerant validated import: normalize/skip/quarantine per schema, LWW.
|
|
633
|
-
// Returns a JSON string { applied, skipped, quarantined }.
|
|
634
|
-
const reportJson = db.importChangesetValidated(args.changesetJson, args.schemasJson);
|
|
635
|
-
const report = JSON.parse(reportJson);
|
|
636
|
-
// Quarantined documents are written to the quarantine table, so a batch
|
|
637
|
-
// that only quarantines still dirties the database. Without this, the IDB
|
|
638
|
-
// fallback would never snapshot them and they would be lost on reload —
|
|
639
|
-
// the opposite of the "never dropped on the floor" guarantee.
|
|
640
|
-
if ((report.applied ?? 0) > 0 || (report.quarantined ?? 0) > 0) onWriteCommitted();
|
|
641
|
-
return reportJson;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
case 'quarantined':
|
|
645
|
-
// JSON array of documents set aside by a validated import.
|
|
646
|
-
return db.quarantined(args.collection);
|
|
726
|
+
case 'isPrimary':
|
|
727
|
+
// Do this tab's writes land authoritatively, or are they forwarded?
|
|
728
|
+
// Both the OPFS lock holder and the IDB-fallback snapshot writer own
|
|
729
|
+
// their storage; only a transient tab has to forward.
|
|
730
|
+
return !isTransientTab();
|
|
647
731
|
|
|
648
732
|
case 'userVersion':
|
|
649
733
|
// Current application migration version (backs openDB({ migrations })).
|
|
@@ -654,13 +738,6 @@ async function dispatch(op, args) {
|
|
|
654
738
|
return null;
|
|
655
739
|
|
|
656
740
|
case 'close':
|
|
657
|
-
// Give accepted HTTP push events a bounded opportunity to finish.
|
|
658
|
-
{
|
|
659
|
-
const deadline = Date.now() + 5000;
|
|
660
|
-
while (db.syncPending() > 0 && Date.now() < deadline) {
|
|
661
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
741
|
// Flush any pending debounced snapshot before releasing the lock so
|
|
665
742
|
// no writes are lost when the tab closes or navigates away.
|
|
666
743
|
await flushSnapshot();
|
|
@@ -668,8 +745,13 @@ async function dispatch(op, args) {
|
|
|
668
745
|
if (releaseLock) { releaseLock(); releaseLock = null; }
|
|
669
746
|
broadcastChannel?.close();
|
|
670
747
|
broadcastChannel = null;
|
|
748
|
+
// Cleared before `db`: a promotion waiting on the lock reads these to
|
|
749
|
+
// decide whether this worker still wants the file.
|
|
671
750
|
idbFallback = false;
|
|
672
751
|
snapshotWriter = false;
|
|
752
|
+
unackedWrites.clear();
|
|
753
|
+
generationBase.clear();
|
|
754
|
+
generationRaw.clear();
|
|
673
755
|
db = null;
|
|
674
756
|
return null;
|
|
675
757
|
|
|
@@ -711,6 +793,81 @@ async function loadOrCreateSalt(root, saltFileName) {
|
|
|
711
793
|
// Initialisation — load WASM, acquire lock, open OPFS file
|
|
712
794
|
// ---------------------------------------------------------------------------
|
|
713
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Wait for the OPFS lock this tab lost at open, and take over when it frees.
|
|
798
|
+
*
|
|
799
|
+
* ## Why a tab has to be able to take over
|
|
800
|
+
*
|
|
801
|
+
* A tab that loses the lock runs an in-memory copy and forwards every write to
|
|
802
|
+
* the tab that owns the file. That arrangement has one failure mode, and it is
|
|
803
|
+
* total: when the owning tab goes away, the survivors keep accepting writes
|
|
804
|
+
* that nobody persists. Closing the last "real" tab silently turned the others
|
|
805
|
+
* into scratch memory.
|
|
806
|
+
*
|
|
807
|
+
* ## What happens to writes made while there was no primary
|
|
808
|
+
*
|
|
809
|
+
* The OPFS file is authoritative on promotion — this tab's in-memory copy is
|
|
810
|
+
* discarded rather than merged, because merging two divergent document sets has
|
|
811
|
+
* no correct answer here. What is *not* discarded is `unackedWrites`: writes
|
|
812
|
+
* the primary never acknowledged, which are therefore absent from the file.
|
|
813
|
+
* Those are replayed, in order, against the freshly-opened OPFS database. A
|
|
814
|
+
* write the old primary *did* apply was acknowledged and is not in the map, so
|
|
815
|
+
* nothing is applied twice — which matters for `$inc`, where a double apply is
|
|
816
|
+
* silently wrong rather than merely redundant.
|
|
817
|
+
*/
|
|
818
|
+
async function waitForPromotion(lockName, fileHandle, openWithOpfs) {
|
|
819
|
+
try {
|
|
820
|
+
await navigator.locks.request(lockName, async () => {
|
|
821
|
+
// A close() between losing the race and winning the lock means this
|
|
822
|
+
// worker is done — take the lock only to release it again.
|
|
823
|
+
if (!idbFallback || !db || !activeDbName) return;
|
|
824
|
+
|
|
825
|
+
let syncHandle;
|
|
826
|
+
try {
|
|
827
|
+
syncHandle = await fileHandle.createSyncAccessHandle();
|
|
828
|
+
} catch (e) {
|
|
829
|
+
warn('Promoted to OPFS owner but the sync handle could not be opened:', e);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const pending = [...unackedWrites.entries()].sort((a, b) => a[0] - b[0]);
|
|
834
|
+
try {
|
|
835
|
+
// The instance swap resets the engine's generation counter.
|
|
836
|
+
carryGenerationsForward();
|
|
837
|
+
db = openWithOpfs(syncHandle);
|
|
838
|
+
idbFallback = false;
|
|
839
|
+
snapshotWriter = true;
|
|
840
|
+
snapshotDirty = false;
|
|
841
|
+
unackedWrites.clear();
|
|
842
|
+
let replayed = 0;
|
|
843
|
+
for (const [, payload] of pending) {
|
|
844
|
+
try {
|
|
845
|
+
replayed += applyForwardedWrite(payload);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
warn('Failed to replay a write during promotion:', err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
log(
|
|
851
|
+
`Promoted to OPFS owner${replayed ? ` (replayed ${replayed} unacknowledged write(s))` : ''}`,
|
|
852
|
+
);
|
|
853
|
+
// Tell the other tabs to re-read: this tab's data is now the file's.
|
|
854
|
+
onWriteCommitted();
|
|
855
|
+
} catch (e) {
|
|
856
|
+
try { syncHandle.close(); } catch { /* best-effort */ }
|
|
857
|
+
warn('Promotion to OPFS owner failed — staying in fallback mode:', e);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Hold the lock for as long as this worker owns the file.
|
|
862
|
+
await new Promise((res) => { releaseLock = res; });
|
|
863
|
+
try { syncHandle.close(); } catch { /* best-effort */ }
|
|
864
|
+
db = null;
|
|
865
|
+
});
|
|
866
|
+
} catch (e) {
|
|
867
|
+
warn('Waiting for the OPFS lock failed:', e);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
714
871
|
async function doInit(dbName, configJson, passphrase = null) {
|
|
715
872
|
encrypted = typeof passphrase === 'string' && passphrase.length > 0;
|
|
716
873
|
|
|
@@ -758,33 +915,49 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
758
915
|
resolve();
|
|
759
916
|
} else if (e.data === 'taladb:snapshot-ready' && idbFallback) {
|
|
760
917
|
snapshotDirty = true;
|
|
761
|
-
} else if (e.data?.type === 'taladb:
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
918
|
+
} else if (e.data?.type === 'taladb:tab-write-ack' && e.data.origin === TAB_ID) {
|
|
919
|
+
// The primary applied one of our forwarded writes: it is now somebody
|
|
920
|
+
// else's durable responsibility, so stop holding it for replay.
|
|
921
|
+
unackedWrites.delete(e.data.seq);
|
|
922
|
+
} else if (e.data?.type === 'taladb:tab-write' && db && snapshotWriter && !encrypted) {
|
|
923
|
+
// Apply a write forwarded by a tab that has no durable storage of its
|
|
924
|
+
// own. Guarded on `snapshotWriter` so only the tab that actually owns
|
|
925
|
+
// persistence applies it — otherwise several fallback tabs would each
|
|
926
|
+
// apply the same write to their private in-memory copies.
|
|
927
|
+
//
|
|
928
|
+
// `!encrypted`: encrypted databases are single-tab by construction, so
|
|
929
|
+
// there is no legitimate forwarded write. Accepting one would let any
|
|
930
|
+
// same-origin script inject documents without knowing the passphrase.
|
|
771
931
|
try {
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
932
|
+
const n = applyForwardedWrite(e.data);
|
|
933
|
+
// Acknowledged whatever the count — the write has been evaluated
|
|
934
|
+
// against authoritative data, and a filter that matched nothing here
|
|
935
|
+
// will not match anything on a replay either. Sent before the
|
|
936
|
+
// notification below so the originating tab can release it promptly.
|
|
937
|
+
if (e.data.origin !== undefined) {
|
|
938
|
+
broadcastChannel.postMessage({
|
|
939
|
+
type: 'taladb:tab-write-ack',
|
|
940
|
+
origin: e.data.origin,
|
|
941
|
+
seq: e.data.seq,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (n > 0) {
|
|
945
|
+
log(`Applied ${n} forwarded write(s) from another tab`);
|
|
946
|
+
// No op/args: this write must not be forwarded onward. Notifying
|
|
947
|
+
// and snapshotting is the whole point — it is what lets the
|
|
948
|
+
// originating tab read its own write back.
|
|
775
949
|
onWriteCommitted();
|
|
776
950
|
}
|
|
777
951
|
} catch (err) {
|
|
778
|
-
warn('Failed to
|
|
952
|
+
warn('Failed to apply a forwarded write from another tab:', err);
|
|
779
953
|
}
|
|
780
954
|
}
|
|
781
955
|
};
|
|
782
956
|
log('BroadcastChannel opened:', `taladb:${dbName}`);
|
|
783
957
|
}
|
|
784
958
|
|
|
785
|
-
// Helpers to open DB with or without
|
|
786
|
-
//
|
|
787
|
-
// HTTP push sync is wired up from the first write.
|
|
959
|
+
// Helpers to open DB with or without a config. The config-aware
|
|
960
|
+
// constructors carry durability and encryption settings.
|
|
788
961
|
function openWithSnapshot(snapshot) {
|
|
789
962
|
const inst = configJson
|
|
790
963
|
? WorkerDB.openWithConfigAndSnapshot(snapshot, configJson)
|
|
@@ -916,6 +1089,12 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
916
1089
|
} else {
|
|
917
1090
|
log('No IDB snapshot yet — starting with empty in-memory database');
|
|
918
1091
|
}
|
|
1092
|
+
// Queue for the lock in the background. The holder releases it when its
|
|
1093
|
+
// tab closes or calls db.close(), and this tab is then promoted to the
|
|
1094
|
+
// OPFS file. Without this a tab that lost the race stayed transient for
|
|
1095
|
+
// its whole life: once the primary went away, every write it made had
|
|
1096
|
+
// nowhere to be persisted and was silently discarded.
|
|
1097
|
+
void waitForPromotion(lockName, fileHandle, openWithOpfs);
|
|
919
1098
|
resolve();
|
|
920
1099
|
return; // Do not hold the lock; returning releases it back to the queue.
|
|
921
1100
|
}
|
|
File without changes
|