@taladb/web 0.10.1 → 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 +8 -3
- package/pkg/taladb_web.d.ts +71 -159
- package/pkg/taladb_web.js +78 -442
- package/pkg/taladb_web_bg.wasm +0 -0
- package/pkg/taladb_web_bg.wasm.d.ts +46 -58
- package/worker/taladb.worker.js +429 -175
- /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,74 @@ 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
|
+
|
|
182
|
+
/**
|
|
183
|
+
* WASM fetch + compile, started at module scope rather than inside `doInit`.
|
|
184
|
+
*
|
|
185
|
+
* The worker is spawned and its module evaluated well before the main thread's
|
|
186
|
+
* `init` message arrives. Waiting for that message to *begin* downloading ~1 MB
|
|
187
|
+
* of WASM left the whole spawn-and-post round trip idle; kicking it off here
|
|
188
|
+
* overlaps the two. `doInit` simply awaits this.
|
|
189
|
+
*
|
|
190
|
+
* A rejection here is not unhandled: `doInit` awaits it and surfaces the error
|
|
191
|
+
* to the caller as a failed `init`. The no-op catch keeps the runtime from
|
|
192
|
+
* reporting it as unhandled in the window between module eval and that await.
|
|
193
|
+
* @type {Promise<typeof import('../pkg/taladb_web')>}
|
|
194
|
+
*/
|
|
195
|
+
const wasmReady = (async () => {
|
|
196
|
+
const wasm = await import(/* @vite-ignore */ '../pkg/taladb_web.js');
|
|
197
|
+
await wasm.default();
|
|
198
|
+
return wasm;
|
|
199
|
+
})();
|
|
200
|
+
wasmReady.catch(() => {});
|
|
201
|
+
|
|
120
202
|
/** The dbName that was successfully initialised (or is being initialised). */
|
|
121
203
|
let activeDbName = null;
|
|
122
204
|
|
|
@@ -134,15 +216,6 @@ const warn = isDev ? console.warn.bind(console, '[TalaDB Worker]') : () => {};
|
|
|
134
216
|
*/
|
|
135
217
|
const WORKER_ORIGIN = typeof location !== 'undefined' ? location.origin : null;
|
|
136
218
|
|
|
137
|
-
/**
|
|
138
|
-
* Per-worker-instance random token. Included in every `taladb:secondary-write`
|
|
139
|
-
* BroadcastChannel message so the primary tab can reject unauthenticated
|
|
140
|
-
* injections that omit a token.
|
|
141
|
-
*/
|
|
142
|
-
const SESSION_TOKEN = (typeof self.crypto !== 'undefined' && typeof self.crypto.randomUUID === 'function')
|
|
143
|
-
? self.crypto.randomUUID()
|
|
144
|
-
: Array.from(self.crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
|
|
145
|
-
|
|
146
219
|
/**
|
|
147
220
|
* Resolving this releases the Web Lock and closes the sync handle.
|
|
148
221
|
* Set inside doInit; called by the 'close' op or when the worker terminates.
|
|
@@ -178,12 +251,6 @@ let snapshotWriter = false;
|
|
|
178
251
|
*/
|
|
179
252
|
let encrypted = false;
|
|
180
253
|
|
|
181
|
-
/**
|
|
182
|
-
* Timestamp (ms) of the last changeset we exported and broadcast to the
|
|
183
|
-
* primary tab. Used as `sinceMs` for the next export so we only send the
|
|
184
|
-
* delta, not the entire database on every write.
|
|
185
|
-
* @type {number}
|
|
186
|
-
*/
|
|
187
254
|
// ---------------------------------------------------------------------------
|
|
188
255
|
// IndexedDB helpers (used only when OPFS is unavailable)
|
|
189
256
|
// ---------------------------------------------------------------------------
|
|
@@ -319,31 +386,116 @@ function applyDurability(inst) {
|
|
|
319
386
|
}
|
|
320
387
|
|
|
321
388
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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`).
|
|
391
|
+
*
|
|
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.
|
|
396
|
+
*/
|
|
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.
|
|
324
411
|
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* taladb:changed so reads stay consistent.
|
|
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.
|
|
329
415
|
*/
|
|
330
|
-
function
|
|
331
|
-
if (!
|
|
416
|
+
function forwardWriteToPrimary(op, args, result) {
|
|
417
|
+
if (!isTransientTab() || !broadcastChannel || !db) return;
|
|
332
418
|
try {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 });
|
|
345
454
|
} catch (err) {
|
|
346
|
-
|
|
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;
|
|
347
499
|
}
|
|
348
500
|
}
|
|
349
501
|
|
|
@@ -351,11 +503,9 @@ function pushChangesetToPrimary() {
|
|
|
351
503
|
* Notify sibling tabs of a write and schedule IDB persistence.
|
|
352
504
|
* Must be called after every mutating op.
|
|
353
505
|
*/
|
|
354
|
-
function onWriteCommitted() {
|
|
506
|
+
function onWriteCommitted(op, args, result) {
|
|
355
507
|
broadcastChannel?.postMessage('taladb:changed');
|
|
356
|
-
|
|
357
|
-
// merged into the authoritative database file.
|
|
358
|
-
pushChangesetToPrimary();
|
|
508
|
+
if (op) forwardWriteToPrimary(op, args, result);
|
|
359
509
|
// Debounced IDB flush — keeps other tabs' fallback instances in sync via
|
|
360
510
|
// BroadcastChannel + snapshotDirty reload without writing to IDB on every op.
|
|
361
511
|
if (snapshotWriter) scheduleSnapshot();
|
|
@@ -393,6 +543,9 @@ async function dispatch(op, args) {
|
|
|
393
543
|
try {
|
|
394
544
|
const fresh = await idbLoadSnapshot(activeDbName);
|
|
395
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();
|
|
396
549
|
db = activeConfigJson
|
|
397
550
|
? WorkerDB.openWithConfigAndSnapshot(fresh, activeConfigJson)
|
|
398
551
|
: WorkerDB.openWithSnapshot(fresh);
|
|
@@ -424,25 +577,13 @@ async function dispatch(op, args) {
|
|
|
424
577
|
switch (op) {
|
|
425
578
|
case 'insert': {
|
|
426
579
|
const result = db.insert(args.collection, args.docJson);
|
|
427
|
-
onWriteCommitted();
|
|
580
|
+
onWriteCommitted('insert', args, result);
|
|
428
581
|
return result;
|
|
429
582
|
}
|
|
430
583
|
|
|
431
584
|
case 'insertMany': {
|
|
432
585
|
const result = db.insertMany(args.collection, args.docsJson);
|
|
433
|
-
onWriteCommitted();
|
|
434
|
-
return result;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
case 'replaceManyWithIds': {
|
|
438
|
-
const result = db.replaceManyWithIds(args.collection, args.docsJson, args.origin);
|
|
439
|
-
onWriteCommitted();
|
|
440
|
-
return result;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
case 'deleteManyWithIds': {
|
|
444
|
-
const result = db.deleteManyWithIds(args.collection, args.idsJson, args.origin);
|
|
445
|
-
onWriteCommitted();
|
|
586
|
+
onWriteCommitted('insertMany', args, result);
|
|
446
587
|
return result;
|
|
447
588
|
}
|
|
448
589
|
|
|
@@ -454,31 +595,38 @@ async function dispatch(op, args) {
|
|
|
454
595
|
|
|
455
596
|
case 'updateOne': {
|
|
456
597
|
const result = db.updateOne(args.collection, args.filterJson, args.updateJson);
|
|
457
|
-
onWriteCommitted();
|
|
598
|
+
onWriteCommitted('updateOne', args, result);
|
|
458
599
|
return result;
|
|
459
600
|
}
|
|
460
601
|
|
|
461
602
|
case 'updateMany': {
|
|
462
603
|
const result = db.updateMany(args.collection, args.filterJson, args.updateJson);
|
|
463
|
-
onWriteCommitted();
|
|
604
|
+
onWriteCommitted('updateMany', args, result);
|
|
464
605
|
return result;
|
|
465
606
|
}
|
|
466
607
|
|
|
467
608
|
case 'deleteOne': {
|
|
468
609
|
const result = db.deleteOne(args.collection, args.filterJson);
|
|
469
|
-
onWriteCommitted();
|
|
610
|
+
onWriteCommitted('deleteOne', args, result);
|
|
470
611
|
return result;
|
|
471
612
|
}
|
|
472
613
|
|
|
473
614
|
case 'deleteMany': {
|
|
474
615
|
const result = db.deleteMany(args.collection, args.filterJson);
|
|
475
|
-
onWriteCommitted();
|
|
616
|
+
onWriteCommitted('deleteMany', args, result);
|
|
476
617
|
return result;
|
|
477
618
|
}
|
|
478
619
|
|
|
479
620
|
case 'count':
|
|
480
621
|
return db.count(args.collection, args.filterJson ?? 'null');
|
|
481
622
|
|
|
623
|
+
// Cheap change-detection for subscribe(): one integer, no query.
|
|
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
|
+
}
|
|
629
|
+
|
|
482
630
|
case 'aggregate':
|
|
483
631
|
return db.aggregate(args.collection, args.pipelineJson ?? '[]');
|
|
484
632
|
|
|
@@ -575,51 +723,11 @@ async function dispatch(op, args) {
|
|
|
575
723
|
await flushSnapshot();
|
|
576
724
|
return null;
|
|
577
725
|
|
|
578
|
-
case '
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
while (db.syncPending() > 0 && Date.now() < deadline) {
|
|
584
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
585
|
-
}
|
|
586
|
-
return db.syncPending() === 0;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
case 'compactTombstones':
|
|
590
|
-
// Prune tombstones older than beforeMs from a collection.
|
|
591
|
-
// Returns the count of tombstones removed.
|
|
592
|
-
return db.compactTombstones(args.collection, args.beforeMs ?? 0);
|
|
593
|
-
|
|
594
|
-
case 'exportChangeset':
|
|
595
|
-
// Export a LWW changeset for the given collections since sinceMs.
|
|
596
|
-
// Returns a JSON string the caller can POST to a sync server.
|
|
597
|
-
return db.exportChangeset(args.collectionsJson, args.sinceMs ?? 0);
|
|
598
|
-
|
|
599
|
-
case 'importChangeset': {
|
|
600
|
-
// Apply a remote changeset (JSON string from sync server) using LWW.
|
|
601
|
-
// Triggers onWriteCommitted so multi-tab peers get notified.
|
|
602
|
-
const applied = db.importChangeset(args.changesetJson);
|
|
603
|
-
if (applied > 0) onWriteCommitted();
|
|
604
|
-
return applied;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
case 'importChangesetValidated': {
|
|
608
|
-
// Tolerant validated import: normalize/skip/quarantine per schema, LWW.
|
|
609
|
-
// Returns a JSON string { applied, skipped, quarantined }.
|
|
610
|
-
const reportJson = db.importChangesetValidated(args.changesetJson, args.schemasJson);
|
|
611
|
-
const report = JSON.parse(reportJson);
|
|
612
|
-
// Quarantined documents are written to the quarantine table, so a batch
|
|
613
|
-
// that only quarantines still dirties the database. Without this, the IDB
|
|
614
|
-
// fallback would never snapshot them and they would be lost on reload —
|
|
615
|
-
// the opposite of the "never dropped on the floor" guarantee.
|
|
616
|
-
if ((report.applied ?? 0) > 0 || (report.quarantined ?? 0) > 0) onWriteCommitted();
|
|
617
|
-
return reportJson;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
case 'quarantined':
|
|
621
|
-
// JSON array of documents set aside by a validated import.
|
|
622
|
-
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();
|
|
623
731
|
|
|
624
732
|
case 'userVersion':
|
|
625
733
|
// Current application migration version (backs openDB({ migrations })).
|
|
@@ -630,13 +738,6 @@ async function dispatch(op, args) {
|
|
|
630
738
|
return null;
|
|
631
739
|
|
|
632
740
|
case 'close':
|
|
633
|
-
// Give accepted HTTP push events a bounded opportunity to finish.
|
|
634
|
-
{
|
|
635
|
-
const deadline = Date.now() + 5000;
|
|
636
|
-
while (db.syncPending() > 0 && Date.now() < deadline) {
|
|
637
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
741
|
// Flush any pending debounced snapshot before releasing the lock so
|
|
641
742
|
// no writes are lost when the tab closes or navigates away.
|
|
642
743
|
await flushSnapshot();
|
|
@@ -644,8 +745,13 @@ async function dispatch(op, args) {
|
|
|
644
745
|
if (releaseLock) { releaseLock(); releaseLock = null; }
|
|
645
746
|
broadcastChannel?.close();
|
|
646
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.
|
|
647
750
|
idbFallback = false;
|
|
648
751
|
snapshotWriter = false;
|
|
752
|
+
unackedWrites.clear();
|
|
753
|
+
generationBase.clear();
|
|
754
|
+
generationRaw.clear();
|
|
649
755
|
db = null;
|
|
650
756
|
return null;
|
|
651
757
|
|
|
@@ -687,15 +793,105 @@ async function loadOrCreateSalt(root, saltFileName) {
|
|
|
687
793
|
// Initialisation — load WASM, acquire lock, open OPFS file
|
|
688
794
|
// ---------------------------------------------------------------------------
|
|
689
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
|
+
|
|
690
871
|
async function doInit(dbName, configJson, passphrase = null) {
|
|
691
|
-
|
|
692
|
-
|
|
872
|
+
encrypted = typeof passphrase === 'string' && passphrase.length > 0;
|
|
873
|
+
|
|
874
|
+
const opfsAvailable = canUseOpfs();
|
|
875
|
+
|
|
876
|
+
// Compiling ~1 MB of WASM and setting up OPFS are independent, and OPFS setup
|
|
877
|
+
// is four awaited round trips (getDirectory → getFileHandle → lock →
|
|
878
|
+
// createSyncAccessHandle). Starting both now lets the storage latency hide
|
|
879
|
+
// behind the compile instead of following it.
|
|
880
|
+
//
|
|
881
|
+
// `wasmReady` was already kicked off at module scope — see its declaration.
|
|
882
|
+
const opfsSetup = opfsAvailable
|
|
883
|
+
? (async () => {
|
|
884
|
+
const root = await navigator.storage.getDirectory();
|
|
885
|
+
const fileName = `taladb_${dbName.replaceAll(/[/\\:]/g, '_')}.redb`;
|
|
886
|
+
return { root, fileName, fileHandle: await root.getFileHandle(fileName, { create: true }) };
|
|
887
|
+
})().catch(() => null)
|
|
888
|
+
: Promise.resolve(null);
|
|
889
|
+
|
|
890
|
+
const wasm = await wasmReady;
|
|
693
891
|
|
|
694
892
|
// Hoist to module scope so snapshot reloads in dispatch() can use it.
|
|
695
893
|
WorkerDB = wasm.WorkerDB;
|
|
696
894
|
|
|
697
|
-
encrypted = typeof passphrase === 'string' && passphrase.length > 0;
|
|
698
|
-
|
|
699
895
|
// Open the BroadcastChannel now that we know the db name.
|
|
700
896
|
if (typeof BroadcastChannel !== 'undefined') {
|
|
701
897
|
broadcastChannel = new BroadcastChannel(`taladb:${dbName}`);
|
|
@@ -719,33 +915,49 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
719
915
|
resolve();
|
|
720
916
|
} else if (e.data === 'taladb:snapshot-ready' && idbFallback) {
|
|
721
917
|
snapshotDirty = true;
|
|
722
|
-
} else if (e.data?.type === 'taladb:
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
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.
|
|
732
931
|
try {
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
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.
|
|
736
949
|
onWriteCommitted();
|
|
737
950
|
}
|
|
738
951
|
} catch (err) {
|
|
739
|
-
warn('Failed to
|
|
952
|
+
warn('Failed to apply a forwarded write from another tab:', err);
|
|
740
953
|
}
|
|
741
954
|
}
|
|
742
955
|
};
|
|
743
956
|
log('BroadcastChannel opened:', `taladb:${dbName}`);
|
|
744
957
|
}
|
|
745
958
|
|
|
746
|
-
// Helpers to open DB with or without
|
|
747
|
-
//
|
|
748
|
-
// 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.
|
|
749
961
|
function openWithSnapshot(snapshot) {
|
|
750
962
|
const inst = configJson
|
|
751
963
|
? WorkerDB.openWithConfigAndSnapshot(snapshot, configJson)
|
|
@@ -762,16 +974,13 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
762
974
|
);
|
|
763
975
|
}
|
|
764
976
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
warn('OPFS unavailable — falling back to IndexedDB-backed in-memory');
|
|
774
|
-
const snapshot = await idbLoadSnapshot(dbName);
|
|
977
|
+
/**
|
|
978
|
+
* Open an in-memory database seeded from the last IndexedDB snapshot, and
|
|
979
|
+
* keep flushing snapshots back. The degradation path whenever OPFS is
|
|
980
|
+
* unusable. Never valid for encrypted databases — the snapshot is plaintext.
|
|
981
|
+
*/
|
|
982
|
+
async function openIdbFallback(name) {
|
|
983
|
+
const snapshot = await idbLoadSnapshot(name);
|
|
775
984
|
db = openWithSnapshot(snapshot);
|
|
776
985
|
idbFallback = true;
|
|
777
986
|
snapshotWriter = true;
|
|
@@ -780,12 +989,24 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
780
989
|
} else {
|
|
781
990
|
log('New in-memory database — writes will be persisted to IndexedDB');
|
|
782
991
|
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// `opfsSetup` resolves to null when OPFS is unusable — either the capability
|
|
995
|
+
// check said so up front, or getDirectory/getFileHandle actually failed.
|
|
996
|
+
const opfs = await opfsSetup;
|
|
997
|
+
if (!opfs) {
|
|
998
|
+
if (encrypted) {
|
|
999
|
+
throw new Error(
|
|
1000
|
+
'TalaDB encryption requires OPFS, which is unavailable in this browser context. ' +
|
|
1001
|
+
'Refusing to open — the in-memory/IndexedDB fallback cannot encrypt at rest.'
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
warn('OPFS unavailable — falling back to IndexedDB-backed in-memory');
|
|
1005
|
+
await openIdbFallback(dbName);
|
|
783
1006
|
return;
|
|
784
1007
|
}
|
|
785
1008
|
|
|
786
|
-
const root =
|
|
787
|
-
const fileName = `taladb_${dbName.replaceAll(/[/\\:]/g, '_')}.redb`;
|
|
788
|
-
const fileHandle = await root.getFileHandle(fileName, { create: true });
|
|
1009
|
+
const { root, fileName, fileHandle } = opfs;
|
|
789
1010
|
|
|
790
1011
|
// Encrypted mode: the 16-byte key-derivation salt lives in an OPFS sidecar
|
|
791
1012
|
// file next to the DB. It is loaded/created only AFTER this tab has won the
|
|
@@ -796,7 +1017,20 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
796
1017
|
// Web Locks not available — open directly (single-tab safe only).
|
|
797
1018
|
warn('Web Locks unavailable — multi-tab write safety disabled');
|
|
798
1019
|
if (encrypted) salt = await loadOrCreateSalt(root, `${fileName}.salt`);
|
|
799
|
-
|
|
1020
|
+
let syncHandle;
|
|
1021
|
+
try {
|
|
1022
|
+
syncHandle = await fileHandle.createSyncAccessHandle();
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
// `createSyncAccessHandle` can exist on the prototype and still throw —
|
|
1025
|
+
// Firefox without storage access, a cross-origin iframe, a revoked
|
|
1026
|
+
// permission. The removed probe file used to surface that as "OPFS
|
|
1027
|
+
// unavailable"; catching it here keeps the same graceful degradation
|
|
1028
|
+
// rather than failing the open outright.
|
|
1029
|
+
if (encrypted) throw e; // the IDB fallback cannot encrypt at rest
|
|
1030
|
+
warn('OPFS sync access unavailable — falling back to IndexedDB:', e);
|
|
1031
|
+
await openIdbFallback(dbName);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
800
1034
|
try {
|
|
801
1035
|
db = openWithOpfs(syncHandle);
|
|
802
1036
|
} catch (e) {
|
|
@@ -855,6 +1089,12 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
855
1089
|
} else {
|
|
856
1090
|
log('No IDB snapshot yet — starting with empty in-memory database');
|
|
857
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);
|
|
858
1098
|
resolve();
|
|
859
1099
|
return; // Do not hold the lock; returning releases it back to the queue.
|
|
860
1100
|
}
|
|
@@ -863,7 +1103,18 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
863
1103
|
let syncHandle = null;
|
|
864
1104
|
try {
|
|
865
1105
|
if (encrypted) salt = await loadOrCreateSalt(root, `${fileName}.salt`);
|
|
866
|
-
|
|
1106
|
+
try {
|
|
1107
|
+
syncHandle = await fileHandle.createSyncAccessHandle();
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
// See the no-locks branch: the method can exist and still throw, and
|
|
1110
|
+
// that used to be caught by the probe file. Degrade to IndexedDB
|
|
1111
|
+
// instead of failing the open.
|
|
1112
|
+
if (encrypted) throw e; // the IDB fallback cannot encrypt at rest
|
|
1113
|
+
warn('OPFS sync access unavailable — falling back to IndexedDB:', e);
|
|
1114
|
+
await openIdbFallback(dbName);
|
|
1115
|
+
resolve();
|
|
1116
|
+
return; // releases the Web Lock — this tab is not the OPFS holder
|
|
1117
|
+
}
|
|
867
1118
|
db = openWithOpfs(syncHandle);
|
|
868
1119
|
snapshotWriter = !encrypted; // encrypted DBs never write a plaintext IDB snapshot
|
|
869
1120
|
log(`Opened "${fileName}" via OPFS (Web Locks)`);
|
|
@@ -893,21 +1144,24 @@ async function doInit(dbName, configJson, passphrase = null) {
|
|
|
893
1144
|
// OPFS capability probe
|
|
894
1145
|
// ---------------------------------------------------------------------------
|
|
895
1146
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1147
|
+
/**
|
|
1148
|
+
* Whether this context can do synchronous OPFS I/O.
|
|
1149
|
+
*
|
|
1150
|
+
* This used to create a uniquely-named probe file, open a sync access handle on
|
|
1151
|
+
* it, close it, and delete it — four sequential OPFS round trips on every cold
|
|
1152
|
+
* start, immediately before the real open performed the identical sequence on
|
|
1153
|
+
* the actual database file and would have failed the same way. The capability
|
|
1154
|
+
* check below is synchronous and free; anything the probe would have caught
|
|
1155
|
+
* (permissions, cross-origin isolation, a non-Worker scope) surfaces from the
|
|
1156
|
+
* real open, which `doInit` already handles by falling back to IndexedDB.
|
|
1157
|
+
*
|
|
1158
|
+
* @returns {boolean}
|
|
1159
|
+
*/
|
|
1160
|
+
function canUseOpfs() {
|
|
1161
|
+
return (
|
|
1162
|
+
typeof navigator !== 'undefined' &&
|
|
1163
|
+
typeof navigator.storage?.getDirectory === 'function' &&
|
|
1164
|
+
typeof FileSystemFileHandle !== 'undefined' &&
|
|
1165
|
+
typeof FileSystemFileHandle.prototype?.createSyncAccessHandle === 'function'
|
|
1166
|
+
);
|
|
913
1167
|
}
|