@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.
@@ -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,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
- * When running as a fallback (non-OPFS) tab, export the changes made since
343
- * 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`).
344
391
  *
345
- * Uses exportChangeset with all known collections to keep the delta small.
346
- * The primary tab's BroadcastChannel handler calls importChangeset() and runs
347
- * its own onWriteCommitted(), which notifies all tabs (including this one) via
348
- * taladb:changed so reads stay consistent.
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 pushChangesetToPrimary() {
351
- if (!idbFallback || !db || !broadcastChannel || !activeDbName) return;
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
- // Collect all collection names from the current in-memory state.
354
- // exportChangeset accepts a JSON array of collection names.
355
- const collections = db.listCollections();
356
- // Wall-clock timestamps are not safe incremental cursors: a write can be
357
- // stamped before an export but commit after its snapshot. Replay is
358
- // idempotent under LWW and cannot skip such a write.
359
- const changeset = db.exportChangeset(collections, 0);
360
- // Only broadcast if there is actually something to send.
361
- const parsed = JSON.parse(changeset);
362
- if (parsed.length === 0) return;
363
- broadcastChannel.postMessage({ type: 'taladb:secondary-write', token: SESSION_TOKEN, changeset });
364
- 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 });
365
454
  } catch (err) {
366
- 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;
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
- // Fallback tab: push local changes to the primary (OPFS) tab so they are
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
- return db.writeGeneration(args.collection);
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 'syncStatus':
603
- return db.syncStatus();
604
-
605
- case 'flushSync': {
606
- const deadline = Date.now() + (args.timeoutMs ?? 5000);
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:secondary-write' && typeof e.data.token === 'string' && e.data.token.length > 0 && db && !encrypted) {
762
- // `!encrypted`: encrypted databases are single-tab, so there are no
763
- // legitimate secondary-tab writes accepting them would let any
764
- // same-origin script inject documents into an encrypted DB without
765
- // knowing the passphrase.
766
- // Merge peer writes in every mode. This also makes multiple tabs
767
- // converge when OPFS is entirely unavailable and every tab uses IDB.
768
- // The token requirement rejects unauthenticated injection attempts that
769
- // omit the SESSION_TOKEN field (not a guarantee against same-origin attackers
770
- // 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.
771
931
  try {
772
- const applied = db.importChangeset(e.data.changeset);
773
- if (applied > 0) {
774
- 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.
775
949
  onWriteCommitted();
776
950
  }
777
951
  } catch (err) {
778
- warn('Failed to import secondary-tab changeset:', err);
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 sync config.
786
- // Uses the config-aware constructors when configJson is provided so that
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