@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.
@@ -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
- * Secondary-tab write propagation
69
- * --------------------------------
70
- * Fallback (non-OPFS) tabs can also make writes that need to reach the primary
71
- * tab's OPFS-backed database. After every mutating op a fallback tab exports a
72
- * mini-changeset (all collections, since the previous sync point) and posts it
73
- * as `{ type: 'taladb:secondary-write', changeset }` on the BroadcastChannel.
74
- * The primary (OPFS) tab receives it, calls importChangeset(), and runs the
75
- * normal onWriteCommitted() path — so all other tabs are notified and the OPFS
76
- * file stays authoritative. LWW merge handles any concurrent edits.
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
- * When running as a fallback (non-OPFS) tab, export the changes made since
323
- * the last push and broadcast them to the primary tab for merging into OPFS.
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
- * Uses exportChangeset with all known collections to keep the delta small.
326
- * The primary tab's BroadcastChannel handler calls importChangeset() and runs
327
- * its own onWriteCommitted(), which notifies all tabs (including this one) via
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 pushChangesetToPrimary() {
331
- if (!idbFallback || !db || !broadcastChannel || !activeDbName) return;
416
+ function forwardWriteToPrimary(op, args, result) {
417
+ if (!isTransientTab() || !broadcastChannel || !db) return;
332
418
  try {
333
- // Collect all collection names from the current in-memory state.
334
- // exportChangeset accepts a JSON array of collection names.
335
- const collections = db.listCollections();
336
- // Wall-clock timestamps are not safe incremental cursors: a write can be
337
- // stamped before an export but commit after its snapshot. Replay is
338
- // idempotent under LWW and cannot skip such a write.
339
- const changeset = db.exportChangeset(collections, 0);
340
- // Only broadcast if there is actually something to send.
341
- const parsed = JSON.parse(changeset);
342
- if (parsed.length === 0) return;
343
- broadcastChannel.postMessage({ type: 'taladb:secondary-write', token: SESSION_TOKEN, changeset });
344
- log(`Broadcast ${parsed.length} change(s) to primary tab`);
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
- warn('Failed to export changeset for primary tab:', err);
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
- // Fallback tab: push local changes to the primary (OPFS) tab so they are
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 'syncStatus':
579
- return db.syncStatus();
580
-
581
- case 'flushSync': {
582
- const deadline = Date.now() + (args.timeoutMs ?? 5000);
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
- const wasm = await import(/* @vite-ignore */ '../pkg/taladb_web.js');
692
- await wasm.default();
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:secondary-write' && typeof e.data.token === 'string' && e.data.token.length > 0 && db && !encrypted) {
723
- // `!encrypted`: encrypted databases are single-tab, so there are no
724
- // legitimate secondary-tab writes accepting them would let any
725
- // same-origin script inject documents into an encrypted DB without
726
- // knowing the passphrase.
727
- // Merge peer writes in every mode. This also makes multiple tabs
728
- // converge when OPFS is entirely unavailable and every tab uses IDB.
729
- // The token requirement rejects unauthenticated injection attempts that
730
- // omit the SESSION_TOKEN field (not a guarantee against same-origin attackers
731
- // who observe the token, but defence-in-depth against naive injection).
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 applied = db.importChangeset(e.data.changeset);
734
- if (applied > 0) {
735
- log(`Merged ${applied} change(s) from secondary tab`);
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 import secondary-tab changeset:', err);
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 sync config.
747
- // Uses the config-aware constructors when configJson is provided so that
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
- const opfsAvailable = await checkOpfs();
766
- if (!opfsAvailable) {
767
- if (encrypted) {
768
- throw new Error(
769
- 'TalaDB encryption requires OPFS, which is unavailable in this browser context. ' +
770
- 'Refusing to open — the in-memory/IndexedDB fallback cannot encrypt at rest.'
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 = await navigator.storage.getDirectory();
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
- const syncHandle = await fileHandle.createSyncAccessHandle();
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
- syncHandle = await fileHandle.createSyncAccessHandle();
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
- async function checkOpfs() {
897
- try {
898
- const root = await navigator.storage.getDirectory();
899
- // Probe createSyncAccessHandle only available in Dedicated Workers.
900
- // getDirectory() succeeding alone is not sufficient.
901
- // Use a unique filename so concurrent workers don't collide on the same
902
- // probe file (each createSyncAccessHandle is exclusive).
903
- const probeName = `_taladb_probe_${Date.now()}_${Math.random().toString(36).slice(2)}`;
904
- const probe = await root.getFileHandle(probeName, { create: true });
905
- if (typeof probe.createSyncAccessHandle !== 'function') return false;
906
- const handle = await probe.createSyncAccessHandle();
907
- handle.close();
908
- await root.removeEntry(probeName);
909
- return true;
910
- } catch {
911
- return false;
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
  }