@taladb/web 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taladb/web",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "TalaDB WASM bindings — document queries and vector search in the browser",
5
5
  "main": "pkg/taladb_web.js",
6
6
  "types": "pkg/taladb_web.d.ts",
package/pkg/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "thinkgrid-labs"
6
6
  ],
7
7
  "description": "TalaDB browser WASM bindings (wasm-bindgen + OPFS)",
8
- "version": "0.6.0",
8
+ "version": "0.6.1",
9
9
  "license": "MIT",
10
10
  "files": [
11
11
  "taladb_web_bg.wasm",
@@ -119,6 +119,20 @@ export class WorkerDB {
119
119
  private constructor();
120
120
  free(): void;
121
121
  [Symbol.dispose](): void;
122
+ /**
123
+ * Remove tombstones older than `before_ms` from the given collection.
124
+ *
125
+ * Call periodically (e.g. on app startup) after your sync retention window
126
+ * has elapsed so deleted document IDs no longer accumulate indefinitely.
127
+ * Returns the number of tombstones removed.
128
+ *
129
+ * ```js
130
+ * // Prune tombstones older than 30 days
131
+ * const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
132
+ * const pruned = db.compactTombstones('users', cutoff);
133
+ * ```
134
+ */
135
+ compactTombstones(collection: string, before_ms: number): number;
122
136
  /**
123
137
  * Count matching documents.
124
138
  */
@@ -148,6 +162,18 @@ export class WorkerDB {
148
162
  * Drop a vector index (and its HNSW graph if present).
149
163
  */
150
164
  dropVectorIndex(collection: string, field: string): void;
165
+ /**
166
+ * Export a changeset for the given collections since `since_ms`.
167
+ *
168
+ * Returns a JSON string representing `Vec<Change>` that can be sent
169
+ * to a remote peer via fetch, WebSocket, or SSE.
170
+ *
171
+ * ```js
172
+ * const json = db.exportChangeset(JSON.stringify(['users', 'posts']), 0);
173
+ * await fetch('/sync', { method: 'POST', body: json });
174
+ * ```
175
+ */
176
+ exportChangeset(collections_json: string, since_ms: number): string;
151
177
  /**
152
178
  * Serialize the entire in-memory database to bytes for persistence.
153
179
  *
@@ -167,6 +193,19 @@ export class WorkerDB {
167
193
  * Find one document. Returns a JSON object or `"null"`.
168
194
  */
169
195
  findOne(collection: string, filter_json: string): string;
196
+ /**
197
+ * Import a remote changeset and merge it into the local database using
198
+ * Last-Write-Wins conflict resolution.
199
+ *
200
+ * Returns the number of documents actually changed.
201
+ *
202
+ * ```js
203
+ * const resp = await fetch('/sync?since=' + lastSync);
204
+ * const applied = db.importChangeset(await resp.text());
205
+ * if (applied > 0) { /* refresh UI */ }
206
+ * ```
207
+ */
208
+ importChangeset(changeset_json: string): number;
170
209
  /**
171
210
  * Insert a document. Returns the new ULID as a string.
172
211
  */
@@ -175,6 +214,11 @@ export class WorkerDB {
175
214
  * Insert many documents. Returns a JSON array of ULID strings.
176
215
  */
177
216
  insertMany(collection: string, docs_json: string): string;
217
+ /**
218
+ * Returns a JSON array of all collection names in the database.
219
+ * Used by the Worker to build the collections list for exportChangeset.
220
+ */
221
+ listCollections(): string;
178
222
  /**
179
223
  * Returns a JSON string `{ btree: string[], fts: string[], vector: string[] }`
180
224
  * listing all indexes on the given collection.
@@ -298,6 +342,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
298
342
  export interface InitOutput {
299
343
  readonly memory: WebAssembly.Memory;
300
344
  readonly __wbg_workerdb_free: (a: number, b: number) => void;
345
+ readonly workerdb_compactTombstones: (a: number, b: number, c: number, d: number) => [number, number, number];
301
346
  readonly workerdb_count: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
302
347
  readonly workerdb_createFtsIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
303
348
  readonly workerdb_createIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
@@ -307,12 +352,15 @@ export interface InitOutput {
307
352
  readonly workerdb_dropFtsIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
308
353
  readonly workerdb_dropIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
309
354
  readonly workerdb_dropVectorIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
355
+ readonly workerdb_exportChangeset: (a: number, b: number, c: number, d: number) => [number, number, number, number];
310
356
  readonly workerdb_exportSnapshot: (a: number) => [number, number, number, number];
311
357
  readonly workerdb_find: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
312
358
  readonly workerdb_findNearest: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => [number, number, number, number];
313
359
  readonly workerdb_findOne: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
360
+ readonly workerdb_importChangeset: (a: number, b: number, c: number) => [number, number, number];
314
361
  readonly workerdb_insert: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
315
362
  readonly workerdb_insertMany: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
363
+ readonly workerdb_listCollections: (a: number) => [number, number, number, number];
316
364
  readonly workerdb_listIndexes: (a: number, b: number, c: number) => [number, number, number, number];
317
365
  readonly workerdb_openInMemory: () => [number, number, number];
318
366
  readonly workerdb_openWithConfigAndOpfs: (a: any, b: number, c: number) => [number, number, number];
@@ -351,12 +399,12 @@ export interface InitOutput {
351
399
  readonly opfs_flush_snapshot: (a: number, b: number, c: number, d: number) => any;
352
400
  readonly opfs_load_snapshot: (a: number, b: number) => any;
353
401
  readonly opfs_open_backend: (a: number, b: number) => any;
354
- readonly wasm_bindgen__closure__destroy__hf3c3b5f99d903397: (a: number, b: number) => void;
402
+ readonly wasm_bindgen__closure__destroy__h72641f1e1c5255af: (a: number, b: number) => void;
355
403
  readonly wasm_bindgen__closure__destroy__h478053cb26acc43f: (a: number, b: number) => void;
356
404
  readonly wasm_bindgen__closure__destroy__h1febc128ce23ccd1: (a: number, b: number) => void;
357
405
  readonly wasm_bindgen__convert__closures_____invoke__h3022e61d849d0b12: (a: number, b: number, c: any) => [number, number];
358
406
  readonly wasm_bindgen__convert__closures_____invoke__h470c93d8a24e0015: (a: number, b: number, c: any, d: any) => void;
359
- readonly wasm_bindgen__convert__closures_____invoke__h524ec4525bd0d07f: (a: number, b: number, c: any) => void;
407
+ readonly wasm_bindgen__convert__closures_____invoke__h296c8d2fd599fbcf: (a: number, b: number, c: any) => void;
360
408
  readonly wasm_bindgen__convert__closures_____invoke__h325a87e086f6f78c: (a: number, b: number) => void;
361
409
  readonly __wbindgen_malloc: (a: number, b: number) => number;
362
410
  readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
package/pkg/taladb_web.js CHANGED
@@ -344,6 +344,31 @@ export class WorkerDB {
344
344
  const ptr = this.__destroy_into_raw();
345
345
  wasm.__wbg_workerdb_free(ptr, 0);
346
346
  }
347
+ /**
348
+ * Remove tombstones older than `before_ms` from the given collection.
349
+ *
350
+ * Call periodically (e.g. on app startup) after your sync retention window
351
+ * has elapsed so deleted document IDs no longer accumulate indefinitely.
352
+ * Returns the number of tombstones removed.
353
+ *
354
+ * ```js
355
+ * // Prune tombstones older than 30 days
356
+ * const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
357
+ * const pruned = db.compactTombstones('users', cutoff);
358
+ * ```
359
+ * @param {string} collection
360
+ * @param {number} before_ms
361
+ * @returns {number}
362
+ */
363
+ compactTombstones(collection, before_ms) {
364
+ const ptr0 = passStringToWasm0(collection, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
365
+ const len0 = WASM_VECTOR_LEN;
366
+ const ret = wasm.workerdb_compactTombstones(this.__wbg_ptr, ptr0, len0, before_ms);
367
+ if (ret[2]) {
368
+ throw takeFromExternrefTable0(ret[1]);
369
+ }
370
+ return ret[0] >>> 0;
371
+ }
347
372
  /**
348
373
  * Count matching documents.
349
374
  * @param {string} collection
@@ -495,6 +520,40 @@ export class WorkerDB {
495
520
  throw takeFromExternrefTable0(ret[0]);
496
521
  }
497
522
  }
523
+ /**
524
+ * Export a changeset for the given collections since `since_ms`.
525
+ *
526
+ * Returns a JSON string representing `Vec<Change>` that can be sent
527
+ * to a remote peer via fetch, WebSocket, or SSE.
528
+ *
529
+ * ```js
530
+ * const json = db.exportChangeset(JSON.stringify(['users', 'posts']), 0);
531
+ * await fetch('/sync', { method: 'POST', body: json });
532
+ * ```
533
+ * @param {string} collections_json
534
+ * @param {number} since_ms
535
+ * @returns {string}
536
+ */
537
+ exportChangeset(collections_json, since_ms) {
538
+ let deferred3_0;
539
+ let deferred3_1;
540
+ try {
541
+ const ptr0 = passStringToWasm0(collections_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
542
+ const len0 = WASM_VECTOR_LEN;
543
+ const ret = wasm.workerdb_exportChangeset(this.__wbg_ptr, ptr0, len0, since_ms);
544
+ var ptr2 = ret[0];
545
+ var len2 = ret[1];
546
+ if (ret[3]) {
547
+ ptr2 = 0; len2 = 0;
548
+ throw takeFromExternrefTable0(ret[2]);
549
+ }
550
+ deferred3_0 = ptr2;
551
+ deferred3_1 = len2;
552
+ return getStringFromWasm0(ptr2, len2);
553
+ } finally {
554
+ wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
555
+ }
556
+ }
498
557
  /**
499
558
  * Serialize the entire in-memory database to bytes for persistence.
500
559
  *
@@ -602,6 +661,29 @@ export class WorkerDB {
602
661
  wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
603
662
  }
604
663
  }
664
+ /**
665
+ * Import a remote changeset and merge it into the local database using
666
+ * Last-Write-Wins conflict resolution.
667
+ *
668
+ * Returns the number of documents actually changed.
669
+ *
670
+ * ```js
671
+ * const resp = await fetch('/sync?since=' + lastSync);
672
+ * const applied = db.importChangeset(await resp.text());
673
+ * if (applied > 0) { /* refresh UI */ }
674
+ * ```
675
+ * @param {string} changeset_json
676
+ * @returns {number}
677
+ */
678
+ importChangeset(changeset_json) {
679
+ const ptr0 = passStringToWasm0(changeset_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
680
+ const len0 = WASM_VECTOR_LEN;
681
+ const ret = wasm.workerdb_importChangeset(this.__wbg_ptr, ptr0, len0);
682
+ if (ret[2]) {
683
+ throw takeFromExternrefTable0(ret[1]);
684
+ }
685
+ return ret[0] >>> 0;
686
+ }
605
687
  /**
606
688
  * Insert a document. Returns the new ULID as a string.
607
689
  * @param {string} collection
@@ -658,6 +740,29 @@ export class WorkerDB {
658
740
  wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
659
741
  }
660
742
  }
743
+ /**
744
+ * Returns a JSON array of all collection names in the database.
745
+ * Used by the Worker to build the collections list for exportChangeset.
746
+ * @returns {string}
747
+ */
748
+ listCollections() {
749
+ let deferred2_0;
750
+ let deferred2_1;
751
+ try {
752
+ const ret = wasm.workerdb_listCollections(this.__wbg_ptr);
753
+ var ptr1 = ret[0];
754
+ var len1 = ret[1];
755
+ if (ret[3]) {
756
+ ptr1 = 0; len1 = 0;
757
+ throw takeFromExternrefTable0(ret[2]);
758
+ }
759
+ deferred2_0 = ptr1;
760
+ deferred2_1 = len1;
761
+ return getStringFromWasm0(ptr1, len1);
762
+ } finally {
763
+ wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
764
+ }
765
+ }
661
766
  /**
662
767
  * Returns a JSON string `{ btree: string[], fts: string[], vector: string[] }`
663
768
  * listing all indexes on the given collection.
@@ -1507,17 +1612,17 @@ function __wbg_get_imports() {
1507
1612
  return ret;
1508
1613
  },
1509
1614
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
1510
- // Cast intrinsic for `Closure(Closure { dtor_idx: 156, function: Function { arguments: [Externref], shim_idx: 157, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
1511
- const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__hf3c3b5f99d903397, wasm_bindgen__convert__closures_____invoke__h524ec4525bd0d07f);
1615
+ // Cast intrinsic for `Closure(Closure { dtor_idx: 102, function: Function { arguments: [Externref], shim_idx: 103, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
1616
+ const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h72641f1e1c5255af, wasm_bindgen__convert__closures_____invoke__h296c8d2fd599fbcf);
1512
1617
  return ret;
1513
1618
  },
1514
1619
  __wbindgen_cast_0000000000000002: function(arg0, arg1) {
1515
- // Cast intrinsic for `Closure(Closure { dtor_idx: 170, function: Function { arguments: [], shim_idx: 171, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
1620
+ // Cast intrinsic for `Closure(Closure { dtor_idx: 181, function: Function { arguments: [], shim_idx: 182, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
1516
1621
  const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h478053cb26acc43f, wasm_bindgen__convert__closures_____invoke__h325a87e086f6f78c);
1517
1622
  return ret;
1518
1623
  },
1519
1624
  __wbindgen_cast_0000000000000003: function(arg0, arg1) {
1520
- // Cast intrinsic for `Closure(Closure { dtor_idx: 575, function: Function { arguments: [Externref], shim_idx: 576, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
1625
+ // Cast intrinsic for `Closure(Closure { dtor_idx: 589, function: Function { arguments: [Externref], shim_idx: 590, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
1521
1626
  const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h1febc128ce23ccd1, wasm_bindgen__convert__closures_____invoke__h3022e61d849d0b12);
1522
1627
  return ret;
1523
1628
  },
@@ -1568,8 +1673,8 @@ function wasm_bindgen__convert__closures_____invoke__h325a87e086f6f78c(arg0, arg
1568
1673
  wasm.wasm_bindgen__convert__closures_____invoke__h325a87e086f6f78c(arg0, arg1);
1569
1674
  }
1570
1675
 
1571
- function wasm_bindgen__convert__closures_____invoke__h524ec4525bd0d07f(arg0, arg1, arg2) {
1572
- wasm.wasm_bindgen__convert__closures_____invoke__h524ec4525bd0d07f(arg0, arg1, arg2);
1676
+ function wasm_bindgen__convert__closures_____invoke__h296c8d2fd599fbcf(arg0, arg1, arg2) {
1677
+ wasm.wasm_bindgen__convert__closures_____invoke__h296c8d2fd599fbcf(arg0, arg1, arg2);
1573
1678
  }
1574
1679
 
1575
1680
  function wasm_bindgen__convert__closures_____invoke__h3022e61d849d0b12(arg0, arg1, arg2) {
Binary file
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable */
3
3
  export const memory: WebAssembly.Memory;
4
4
  export const __wbg_workerdb_free: (a: number, b: number) => void;
5
+ export const workerdb_compactTombstones: (a: number, b: number, c: number, d: number) => [number, number, number];
5
6
  export const workerdb_count: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
6
7
  export const workerdb_createFtsIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
7
8
  export const workerdb_createIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
@@ -11,12 +12,15 @@ export const workerdb_deleteOne: (a: number, b: number, c: number, d: number, e:
11
12
  export const workerdb_dropFtsIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
12
13
  export const workerdb_dropIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
13
14
  export const workerdb_dropVectorIndex: (a: number, b: number, c: number, d: number, e: number) => [number, number];
15
+ export const workerdb_exportChangeset: (a: number, b: number, c: number, d: number) => [number, number, number, number];
14
16
  export const workerdb_exportSnapshot: (a: number) => [number, number, number, number];
15
17
  export const workerdb_find: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
16
18
  export const workerdb_findNearest: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => [number, number, number, number];
17
19
  export const workerdb_findOne: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
20
+ export const workerdb_importChangeset: (a: number, b: number, c: number) => [number, number, number];
18
21
  export const workerdb_insert: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
19
22
  export const workerdb_insertMany: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
23
+ export const workerdb_listCollections: (a: number) => [number, number, number, number];
20
24
  export const workerdb_listIndexes: (a: number, b: number, c: number) => [number, number, number, number];
21
25
  export const workerdb_openInMemory: () => [number, number, number];
22
26
  export const workerdb_openWithConfigAndOpfs: (a: any, b: number, c: number) => [number, number, number];
@@ -55,12 +59,12 @@ export const opfs_delete_snapshot: (a: number, b: number) => any;
55
59
  export const opfs_flush_snapshot: (a: number, b: number, c: number, d: number) => any;
56
60
  export const opfs_load_snapshot: (a: number, b: number) => any;
57
61
  export const opfs_open_backend: (a: number, b: number) => any;
58
- export const wasm_bindgen__closure__destroy__hf3c3b5f99d903397: (a: number, b: number) => void;
62
+ export const wasm_bindgen__closure__destroy__h72641f1e1c5255af: (a: number, b: number) => void;
59
63
  export const wasm_bindgen__closure__destroy__h478053cb26acc43f: (a: number, b: number) => void;
60
64
  export const wasm_bindgen__closure__destroy__h1febc128ce23ccd1: (a: number, b: number) => void;
61
65
  export const wasm_bindgen__convert__closures_____invoke__h3022e61d849d0b12: (a: number, b: number, c: any) => [number, number];
62
66
  export const wasm_bindgen__convert__closures_____invoke__h470c93d8a24e0015: (a: number, b: number, c: any, d: any) => void;
63
- export const wasm_bindgen__convert__closures_____invoke__h524ec4525bd0d07f: (a: number, b: number, c: any) => void;
67
+ export const wasm_bindgen__convert__closures_____invoke__h296c8d2fd599fbcf: (a: number, b: number, c: any) => void;
64
68
  export const wasm_bindgen__convert__closures_____invoke__h325a87e086f6f78c: (a: number, b: number) => void;
65
69
  export const __wbindgen_malloc: (a: number, b: number) => number;
66
70
  export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
@@ -48,6 +48,10 @@
48
48
  * dropVectorIndex { collection, field }
49
49
  * upgradeVectorIndex { collection, field }
50
50
  * findNearest { collection, field, queryJson, topK, filterJson? }
51
+ * listCollections {} → JSON string[]
52
+ * compactTombstones { collection, beforeMs } → number pruned
53
+ * exportChangeset { collectionsJson, sinceMs? } → JSON changeset string
54
+ * importChangeset { changesetJson } → number of applied changes
51
55
  * close {}
52
56
  *
53
57
  * Multi-tab live queries (BroadcastChannel)
@@ -57,6 +61,16 @@
57
61
  * `"taladb:<dbName>"`. Other tabs listening on the same channel re-trigger
58
62
  * their active `subscribe()` pollers immediately, bypassing the 300 ms tick.
59
63
  *
64
+ * Secondary-tab write propagation
65
+ * --------------------------------
66
+ * Fallback (non-OPFS) tabs can also make writes that need to reach the primary
67
+ * tab's OPFS-backed database. After every mutating op a fallback tab exports a
68
+ * mini-changeset (all collections, since the previous sync point) and posts it
69
+ * as `{ type: 'taladb:secondary-write', changeset }` on the BroadcastChannel.
70
+ * The primary (OPFS) tab receives it, calls importChangeset(), and runs the
71
+ * normal onWriteCommitted() path — so all other tabs are notified and the OPFS
72
+ * file stays authoritative. LWW merge handles any concurrent edits.
73
+ *
60
74
  * IndexedDB fallback (no OPFS)
61
75
  * ----------------------------
62
76
  * When OPFS is unavailable (cross-origin iframes, Firefox without storage
@@ -130,6 +144,14 @@ let broadcastChannel = null;
130
144
  */
131
145
  let idbFallback = false;
132
146
 
147
+ /**
148
+ * Timestamp (ms) of the last changeset we exported and broadcast to the
149
+ * primary tab. Used as `sinceMs` for the next export so we only send the
150
+ * delta, not the entire database on every write.
151
+ * @type {number}
152
+ */
153
+ let lastSecondaryPushMs = 0;
154
+
133
155
  // ---------------------------------------------------------------------------
134
156
  // IndexedDB helpers (used only when OPFS is unavailable)
135
157
  // ---------------------------------------------------------------------------
@@ -193,22 +215,99 @@ async function idbSaveSnapshot(dbName, bytes) {
193
215
  } catch { /* best-effort persistence — ignore failures */ }
194
216
  }
195
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // Debounced IDB snapshot
220
+ // ---------------------------------------------------------------------------
221
+
196
222
  /**
197
- * Notify sibling tabs of a write and, when in IDB-fallback mode, flush the
198
- * updated snapshot to IndexedDB. Must be called after every mutating op.
223
+ * Debounce + max-interval parameters for IDB snapshot persistence.
224
+ *
225
+ * On every write we notify sibling tabs immediately via BroadcastChannel,
226
+ * but we defer the actual IDB persistence so that bulk inserts (insertMany,
227
+ * rapid sequential inserts) only produce a single IDB write rather than one
228
+ * per document. A max-interval cap ensures data is never more than 5 s stale
229
+ * in IDB even under continuous write load.
199
230
  */
200
- function onWriteCommitted() {
201
- broadcastChannel?.postMessage('taladb:changed');
202
- // Always flush a snapshot to IDB after every write — this keeps other tabs'
203
- // IDB-fallback instances in sync via BroadcastChannel + snapshotDirty reload.
231
+ const SNAPSHOT_DEBOUNCE_MS = 500;
232
+ const SNAPSHOT_MAX_INTERVAL_MS = 5000;
233
+
234
+ /** setTimeout handle for the pending debounced flush. */
235
+ let snapshotTimer = null;
236
+
237
+ /** Timestamp of the last completed IDB flush (ms since epoch). */
238
+ let lastSnapshotMs = 0;
239
+
240
+ /** Perform the IDB snapshot write and reset state. */
241
+ async function flushSnapshot() {
242
+ clearTimeout(snapshotTimer);
243
+ snapshotTimer = null;
244
+ lastSnapshotMs = Date.now();
204
245
  if (db && activeDbName) {
205
246
  try {
206
247
  const bytes = db.exportSnapshot();
207
- idbSaveSnapshot(activeDbName, bytes).catch(() => {});
208
- } catch { /* ignore snapshot export errors */ }
248
+ await idbSaveSnapshot(activeDbName, bytes);
249
+ } catch { /* best-effort ignore failures */ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Schedule (or immediately trigger) an IDB snapshot write.
255
+ *
256
+ * - If the last flush was more than SNAPSHOT_MAX_INTERVAL_MS ago, flush now.
257
+ * - Otherwise debounce: reset the timer to fire SNAPSHOT_DEBOUNCE_MS from now.
258
+ */
259
+ function scheduleSnapshot() {
260
+ const now = Date.now();
261
+ if (now - lastSnapshotMs > SNAPSHOT_MAX_INTERVAL_MS) {
262
+ // Overdue — flush synchronously in the microtask queue.
263
+ flushSnapshot().catch(() => {});
264
+ return;
265
+ }
266
+ clearTimeout(snapshotTimer);
267
+ snapshotTimer = setTimeout(() => { flushSnapshot().catch(() => {}); }, SNAPSHOT_DEBOUNCE_MS);
268
+ }
269
+
270
+ /**
271
+ * When running as a fallback (non-OPFS) tab, export the changes made since
272
+ * the last push and broadcast them to the primary tab for merging into OPFS.
273
+ *
274
+ * Uses exportChangeset with all known collections to keep the delta small.
275
+ * The primary tab's BroadcastChannel handler calls importChangeset() and runs
276
+ * its own onWriteCommitted(), which notifies all tabs (including this one) via
277
+ * taladb:changed so reads stay consistent.
278
+ */
279
+ function pushChangesetToPrimary() {
280
+ if (!idbFallback || !db || !broadcastChannel || !activeDbName) return;
281
+ try {
282
+ // Collect all collection names from the current in-memory state.
283
+ // exportChangeset accepts a JSON array of collection names.
284
+ const collections = db.listCollections();
285
+ const changeset = db.exportChangeset(collections, lastSecondaryPushMs);
286
+ // Only broadcast if there is actually something to send.
287
+ const parsed = JSON.parse(changeset);
288
+ if (parsed.length === 0) return;
289
+ lastSecondaryPushMs = Date.now();
290
+ broadcastChannel.postMessage({ type: 'taladb:secondary-write', changeset });
291
+ log(`Broadcast ${parsed.length} change(s) to primary tab`);
292
+ } catch (err) {
293
+ warn('Failed to export changeset for primary tab:', err);
209
294
  }
210
295
  }
211
296
 
297
+ /**
298
+ * Notify sibling tabs of a write and schedule IDB persistence.
299
+ * Must be called after every mutating op.
300
+ */
301
+ function onWriteCommitted() {
302
+ broadcastChannel?.postMessage('taladb:changed');
303
+ // Fallback tab: push local changes to the primary (OPFS) tab so they are
304
+ // merged into the authoritative database file.
305
+ pushChangesetToPrimary();
306
+ // Debounced IDB flush — keeps other tabs' fallback instances in sync via
307
+ // BroadcastChannel + snapshotDirty reload without writing to IDB on every op.
308
+ scheduleSnapshot();
309
+ }
310
+
212
311
  // ---------------------------------------------------------------------------
213
312
  // Dedicated Worker message handler
214
313
  // ---------------------------------------------------------------------------
@@ -357,7 +456,31 @@ async function dispatch(op, args) {
357
456
  args.filterJson ?? 'null',
358
457
  );
359
458
 
459
+ case 'listCollections':
460
+ return db.listCollections();
461
+
462
+ case 'compactTombstones':
463
+ // Prune tombstones older than beforeMs from a collection.
464
+ // Returns the count of tombstones removed.
465
+ return db.compactTombstones(args.collection, args.beforeMs ?? 0);
466
+
467
+ case 'exportChangeset':
468
+ // Export a LWW changeset for the given collections since sinceMs.
469
+ // Returns a JSON string the caller can POST to a sync server.
470
+ return db.exportChangeset(args.collectionsJson, args.sinceMs ?? 0);
471
+
472
+ case 'importChangeset': {
473
+ // Apply a remote changeset (JSON string from sync server) using LWW.
474
+ // Triggers onWriteCommitted so multi-tab peers get notified.
475
+ const applied = db.importChangeset(args.changesetJson);
476
+ if (applied > 0) onWriteCommitted();
477
+ return applied;
478
+ }
479
+
360
480
  case 'close':
481
+ // Flush any pending debounced snapshot before releasing the lock so
482
+ // no writes are lost when the tab closes or navigates away.
483
+ await flushSnapshot();
361
484
  // Release the Web Lock and close the sync handle gracefully.
362
485
  if (releaseLock) { releaseLock(); releaseLock = null; }
363
486
  broadcastChannel?.close();
@@ -402,6 +525,17 @@ async function doInit(dbName, configJson) {
402
525
  const resolve = pendingSnapshotResolve;
403
526
  pendingSnapshotResolve = null;
404
527
  resolve();
528
+ } else if (e.data?.type === 'taladb:secondary-write' && !idbFallback && db) {
529
+ // Primary (OPFS) tab: a secondary tab made a write — merge it in via LWW.
530
+ try {
531
+ const applied = db.importChangeset(e.data.changeset);
532
+ if (applied > 0) {
533
+ log(`Merged ${applied} change(s) from secondary tab`);
534
+ onWriteCommitted();
535
+ }
536
+ } catch (err) {
537
+ warn('Failed to import secondary-tab changeset:', err);
538
+ }
405
539
  }
406
540
  };
407
541
  log('BroadcastChannel opened:', `taladb:${dbName}`);