cry-synced-db-client 0.1.47 → 0.1.50

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/dist/index.js CHANGED
@@ -1097,8 +1097,7 @@ class SyncedDb {
1097
1097
  onWsNotification;
1098
1098
  onOnlineStatusChange;
1099
1099
  crossTabSyncDebounceMs;
1100
- onExternalSync;
1101
- onStaleMetaConflict;
1100
+ onCrossTabSync;
1102
1101
  onBecameLeader;
1103
1102
  onLostLeadership;
1104
1103
  onInfrastructureError;
@@ -1117,8 +1116,9 @@ class SyncedDb {
1117
1116
  updaterId;
1118
1117
  syncedDbInstanceId;
1119
1118
  syncMetaCache = new Map;
1120
- lastSeenRevCache = new Map;
1121
- unsubscribeMetaUpdates;
1119
+ metaUpdateChannel;
1120
+ pendingBroadcasts = new Map;
1121
+ broadcastDebounceTimer;
1122
1122
  isLeader = false;
1123
1123
  releaseLeaderLockResolve;
1124
1124
  leaderReelectionChannel;
@@ -1154,8 +1154,7 @@ class SyncedDb {
1154
1154
  this.onWsNotification = config.onWsNotification;
1155
1155
  this.onOnlineStatusChange = config.onOnlineStatusChange;
1156
1156
  this.crossTabSyncDebounceMs = config.crossTabSyncDebounceMs ?? DEFAULT_CROSS_TAB_SYNC_DEBOUNCE_MS;
1157
- this.onExternalSync = config.onExternalSync;
1158
- this.onStaleMetaConflict = config.onStaleMetaConflict;
1157
+ this.onCrossTabSync = config.onCrossTabSync;
1159
1158
  this.onBecameLeader = config.onBecameLeader;
1160
1159
  this.onLostLeadership = config.onLostLeadership;
1161
1160
  this.onInfrastructureError = config.onInfrastructureError;
@@ -1174,6 +1173,9 @@ class SyncedDb {
1174
1173
  isLeaderTab() {
1175
1174
  return this.isLeader;
1176
1175
  }
1176
+ simulateExternalBroadcast(payload) {
1177
+ this.handleCrossTabMetaUpdate(payload);
1178
+ }
1177
1179
  tryBecomeLeader() {
1178
1180
  if (this.closing) {
1179
1181
  return Promise.resolve();
@@ -1343,9 +1345,6 @@ class SyncedDb {
1343
1345
  this.syncMetaCache.set(name, meta);
1344
1346
  }
1345
1347
  }
1346
- this.unsubscribeMetaUpdates = this.dexieDb.onMetaUpdated((payload) => {
1347
- this.handleCrossTabMetaUpdate(payload);
1348
- });
1349
1348
  if (typeof BroadcastChannel !== "undefined") {
1350
1349
  this.leaderReelectionChannel = new BroadcastChannel(`synced-db-leader-reelection-${this.tenant}`);
1351
1350
  this.leaderReelectionChannel.onmessage = (event) => {
@@ -1353,6 +1352,10 @@ class SyncedDb {
1353
1352
  this.handleLeaderReelection();
1354
1353
  }
1355
1354
  };
1355
+ this.metaUpdateChannel = new BroadcastChannel(`synced-db-meta-${this.tenant}`);
1356
+ this.metaUpdateChannel.onmessage = (event) => {
1357
+ this.handleCrossTabMetaUpdate(event.data);
1358
+ };
1356
1359
  }
1357
1360
  if (typeof document === "undefined" || document.visibilityState === "visible") {
1358
1361
  this.tryBecomeLeader();
@@ -1402,9 +1405,10 @@ class SyncedDb {
1402
1405
  }
1403
1406
  this.stopAutoSync();
1404
1407
  await this.flushAllPendingChanges();
1405
- if (this.unsubscribeMetaUpdates) {
1406
- this.unsubscribeMetaUpdates();
1407
- this.unsubscribeMetaUpdates = undefined;
1408
+ this.flushBroadcasts();
1409
+ if (this.metaUpdateChannel) {
1410
+ this.metaUpdateChannel.close();
1411
+ this.metaUpdateChannel = undefined;
1408
1412
  }
1409
1413
  this.releaseLeaderLock();
1410
1414
  if (this.leaderReelectionChannel) {
@@ -1803,7 +1807,7 @@ class SyncedDb {
1803
1807
  const syncSpecs = [];
1804
1808
  const configMap = new Map;
1805
1809
  for (const [collectionName, config] of this.collections) {
1806
- const meta = await this.dexieDb.getSyncMeta(collectionName);
1810
+ const meta = this.syncMetaCache.get(collectionName);
1807
1811
  syncSpecs.push({
1808
1812
  collection: collectionName,
1809
1813
  timestamp: meta?.lastSyncTs || 0,
@@ -1944,59 +1948,68 @@ class SyncedDb {
1944
1948
  }
1945
1949
  return result;
1946
1950
  }
1947
- async deleteCollectionData(collection, force = false) {
1951
+ async dropCollection(collection, force = false) {
1948
1952
  this.assertCollection(collection);
1953
+ if (!force && (this.forcedOffline || !this.online)) {
1954
+ throw new Error(`Cannot drop collection "${collection}": database is offline. Use force=true to drop anyway.`);
1955
+ }
1949
1956
  await this.flushPendingChangesForCollection(collection);
1950
- let sendSuccess = true;
1951
- if (this.isOnline()) {
1952
- try {
1953
- await this.uploadDirtyItemsForCollection(collection);
1954
- } catch (err) {
1955
- console.error(`Failed to send dirty data for ${collection}:`, err);
1956
- sendSuccess = false;
1957
+ const dirtyItems = await this.dexieDb.getDirty(collection);
1958
+ if (dirtyItems.length > 0) {
1959
+ if (!force && (this.forcedOffline || !this.online)) {
1960
+ throw new Error(`Cannot drop collection "${collection}": ${dirtyItems.length} unsynchronized items would be lost. Use force=true to drop anyway.`);
1957
1961
  }
1958
- } else {
1959
- const dirtyItems = await this.dexieDb.getDirty(collection);
1960
- if (dirtyItems.length > 0) {
1961
- sendSuccess = false;
1962
+ if (this.online && !this.forcedOffline) {
1963
+ try {
1964
+ await this.uploadDirtyItemsForCollection(collection);
1965
+ } catch (err) {
1966
+ if (!force) {
1967
+ throw new Error(`Cannot drop collection "${collection}": failed to sync dirty items to server. Use force=true to drop anyway. Original error: ${err}`);
1968
+ }
1969
+ }
1962
1970
  }
1963
1971
  }
1964
- if (!sendSuccess && !force) {
1965
- return false;
1966
- }
1967
1972
  await this.dexieDb.deleteCollection(collection);
1968
1973
  this.inMemDb.deleteCollection(collection);
1969
1974
  await this.dexieDb.deleteSyncMeta(collection);
1970
- return true;
1975
+ await this.dexieDb.clearDirtyChanges(collection);
1976
+ this.syncMetaCache.delete(collection);
1971
1977
  }
1972
- async deleteAllCollectionsData(force = false) {
1978
+ async dropDatabase(force = false) {
1979
+ if (!force && (this.forcedOffline || !this.online)) {
1980
+ throw new Error("Cannot drop database: database is offline. Use force=true to drop anyway.");
1981
+ }
1973
1982
  await this.flushAllPendingChanges();
1974
- let sendSuccess = true;
1975
- if (this.isOnline()) {
1976
- try {
1977
- await this.uploadDirtyItems();
1978
- } catch (err) {
1979
- console.error("Failed to send dirty data:", err);
1980
- sendSuccess = false;
1983
+ const collectionNames = Array.from(this.collections.keys());
1984
+ const dirtyCollections = [];
1985
+ for (const collectionName of collectionNames) {
1986
+ const dirtyItems = await this.dexieDb.getDirty(collectionName);
1987
+ if (dirtyItems.length > 0) {
1988
+ dirtyCollections.push({ name: collectionName, count: dirtyItems.length });
1981
1989
  }
1982
- } else {
1983
- for (const [collectionName] of this.collections) {
1984
- const dirtyItems = await this.dexieDb.getDirty(collectionName);
1985
- if (dirtyItems.length > 0) {
1986
- sendSuccess = false;
1987
- break;
1990
+ }
1991
+ if (dirtyCollections.length > 0) {
1992
+ const totalDirty = dirtyCollections.reduce((sum, c) => sum + c.count, 0);
1993
+ if (!force && (this.forcedOffline || !this.online)) {
1994
+ throw new Error(`Cannot drop database: ${totalDirty} unsynchronized items in ${dirtyCollections.length} collections would be lost. Use force=true to drop anyway.`);
1995
+ }
1996
+ if (this.online && !this.forcedOffline) {
1997
+ try {
1998
+ await this.uploadDirtyItems();
1999
+ } catch (err) {
2000
+ if (!force) {
2001
+ throw new Error(`Cannot drop database: failed to sync dirty items to server. Use force=true to drop anyway. Original error: ${err}`);
2002
+ }
1988
2003
  }
1989
2004
  }
1990
2005
  }
1991
- if (!sendSuccess && !force) {
1992
- return false;
1993
- }
1994
- for (const [collectionName] of this.collections) {
2006
+ for (const collectionName of collectionNames) {
1995
2007
  await this.dexieDb.deleteCollection(collectionName);
1996
2008
  this.inMemDb.deleteCollection(collectionName);
1997
2009
  await this.dexieDb.deleteSyncMeta(collectionName);
2010
+ await this.dexieDb.clearDirtyChanges(collectionName);
1998
2011
  }
1999
- return true;
2012
+ this.syncMetaCache.clear();
2000
2013
  }
2001
2014
  assertCollection(name) {
2002
2015
  if (!this.collections.has(name)) {
@@ -2245,6 +2258,27 @@ class SyncedDb {
2245
2258
  this.inMemDb.deleteManyByIds(collection, deleteIds);
2246
2259
  sentCount += deleted.length;
2247
2260
  }
2261
+ const allItems = [...inserted, ...updated, ...deleted];
2262
+ let maxTs = undefined;
2263
+ for (const item of allItems) {
2264
+ if (item._ts) {
2265
+ if (!maxTs || this.compareTimestamps(item._ts, maxTs) > 0) {
2266
+ maxTs = item._ts;
2267
+ }
2268
+ }
2269
+ }
2270
+ if (maxTs) {
2271
+ const currentMeta = this.syncMetaCache.get(collection);
2272
+ const currentTs = currentMeta?.lastSyncTs;
2273
+ if (!currentTs || this.compareTimestamps(maxTs, currentTs) > 0) {
2274
+ await this.dexieDb.setSyncMeta(collection, maxTs);
2275
+ this.syncMetaCache.set(collection, {
2276
+ tenant: this.tenant,
2277
+ collection,
2278
+ lastSyncTs: maxTs
2279
+ });
2280
+ }
2281
+ }
2248
2282
  }
2249
2283
  return { sentCount };
2250
2284
  }
@@ -2497,6 +2531,27 @@ class SyncedDb {
2497
2531
  this.inMemDb.deleteManyByIds(collection, deleteIds);
2498
2532
  sentCount += deleted.length;
2499
2533
  }
2534
+ const allItems = [...inserted, ...updated, ...deleted];
2535
+ let maxTs = undefined;
2536
+ for (const item of allItems) {
2537
+ if (item._ts) {
2538
+ if (!maxTs || this.compareTimestamps(item._ts, maxTs) > 0) {
2539
+ maxTs = item._ts;
2540
+ }
2541
+ }
2542
+ }
2543
+ if (maxTs) {
2544
+ const currentMeta = this.syncMetaCache.get(collection);
2545
+ const currentTs = currentMeta?.lastSyncTs;
2546
+ if (!currentTs || this.compareTimestamps(maxTs, currentTs) > 0) {
2547
+ await this.dexieDb.setSyncMeta(collection, maxTs);
2548
+ this.syncMetaCache.set(collection, {
2549
+ tenant: this.tenant,
2550
+ collection,
2551
+ lastSyncTs: maxTs
2552
+ });
2553
+ }
2554
+ }
2500
2555
  if (errors) {
2501
2556
  console.error(`Sync errors for ${collection}:`, errors);
2502
2557
  }
@@ -2547,74 +2602,83 @@ class SyncedDb {
2547
2602
  }
2548
2603
  return false;
2549
2604
  }
2550
- normalizeTimestamp(ts) {
2551
- if (ts === null || ts === undefined)
2552
- return 0;
2553
- if (typeof ts === "number")
2554
- return ts;
2555
- if (ts instanceof Date)
2556
- return ts.getTime();
2557
- if (typeof ts === "object" && "t" in ts)
2558
- return ts.t;
2559
- if (typeof ts.toNumber === "function")
2560
- return ts.toNumber();
2561
- if (typeof ts.valueOf === "function")
2562
- return ts.valueOf();
2563
- return 0;
2564
- }
2565
- async broadcastMetaUpdate(collection, timestamp) {
2566
- const cachedMeta = this.syncMetaCache.get(collection);
2567
- const cachedTs = this.normalizeTimestamp(cachedMeta?.lastSyncTs);
2568
- const newTs = this.normalizeTimestamp(timestamp);
2569
- if (newTs > cachedTs) {
2570
- await this.dexieDb.setSyncMeta(collection, timestamp);
2571
- this.syncMetaCache.set(collection, {
2572
- tenant: this.tenant,
2573
- collection,
2574
- lastSyncTs: timestamp
2575
- });
2605
+ broadcastMetaUpdate(updates) {
2606
+ if (!this.metaUpdateChannel)
2607
+ return;
2608
+ for (const [collection, ids] of Object.entries(updates)) {
2609
+ let existing = this.pendingBroadcasts.get(collection);
2610
+ if (!existing) {
2611
+ existing = new Set;
2612
+ this.pendingBroadcasts.set(collection, existing);
2613
+ }
2614
+ for (const id of ids) {
2615
+ existing.add(id);
2616
+ }
2617
+ }
2618
+ if (this.broadcastDebounceTimer) {
2619
+ clearTimeout(this.broadcastDebounceTimer);
2620
+ }
2621
+ this.broadcastDebounceTimer = setTimeout(() => {
2622
+ this.flushBroadcasts();
2623
+ }, this.crossTabSyncDebounceMs);
2624
+ }
2625
+ flushBroadcasts() {
2626
+ if (!this.metaUpdateChannel)
2627
+ return;
2628
+ if (this.broadcastDebounceTimer) {
2629
+ clearTimeout(this.broadcastDebounceTimer);
2630
+ this.broadcastDebounceTimer = undefined;
2631
+ }
2632
+ const updates = {};
2633
+ for (const [collection, ids] of this.pendingBroadcasts) {
2634
+ updates[collection] = Array.from(ids);
2576
2635
  }
2636
+ if (Object.keys(updates).length > 0) {
2637
+ const payload = {
2638
+ updates,
2639
+ instanceId: this.syncedDbInstanceId
2640
+ };
2641
+ this.metaUpdateChannel.postMessage(payload);
2642
+ }
2643
+ this.pendingBroadcasts.clear();
2577
2644
  }
2578
2645
  async handleCrossTabMetaUpdate(payload) {
2579
- if (payload.instanceId === this.dexieDb.getInstanceId()) {
2646
+ if (!this.initialized) {
2580
2647
  return;
2581
2648
  }
2582
- const collection = payload.collection;
2583
- if (!this.collections.has(collection)) {
2649
+ if (payload.instanceId === this.syncedDbInstanceId) {
2584
2650
  return;
2585
2651
  }
2586
- const lastSeenRev = this.lastSeenRevCache.get(collection) ?? 0;
2587
- try {
2588
- const newMeta = await this.dexieDb.getSyncMeta(collection);
2589
- if (newMeta) {
2590
- this.syncMetaCache.set(collection, newMeta);
2591
- }
2592
- const newerItems = await this.dexieDb.getNewerThan(collection, lastSeenRev);
2593
- let newItemsCount = 0;
2594
- let maxRev = lastSeenRev;
2595
- for (const item of newerItems) {
2596
- if (item._rev !== undefined && item._rev > maxRev) {
2597
- maxRev = item._rev;
2598
- }
2599
- if (item._deleted) {
2600
- this.inMemDb.deleteOne(collection, item._id);
2601
- } else {
2602
- this.inMemDb.save(collection, item._id, this.stripLocalFields(item));
2603
- }
2604
- newItemsCount++;
2652
+ for (const [collection, ids] of Object.entries(payload.updates)) {
2653
+ if (!this.collections.has(collection)) {
2654
+ continue;
2605
2655
  }
2606
- if (maxRev > lastSeenRev) {
2607
- this.lastSeenRevCache.set(collection, maxRev);
2656
+ if (ids.length === 0) {
2657
+ continue;
2608
2658
  }
2609
- if (this.onExternalSync) {
2610
- try {
2611
- this.onExternalSync(collection, newItemsCount);
2612
- } catch (err) {
2613
- console.error("onExternalSync callback failed:", err);
2659
+ try {
2660
+ const items = await this.dexieDb.getByIds(collection, ids);
2661
+ let newItemsCount = 0;
2662
+ for (const item of items) {
2663
+ if (!item)
2664
+ continue;
2665
+ if (item._deleted) {
2666
+ this.inMemDb.deleteOne(collection, item._id);
2667
+ } else {
2668
+ this.inMemDb.save(collection, item._id, this.stripLocalFields(item));
2669
+ }
2670
+ newItemsCount++;
2614
2671
  }
2672
+ if (this.onCrossTabSync && newItemsCount > 0) {
2673
+ try {
2674
+ this.onCrossTabSync(collection, newItemsCount);
2675
+ } catch (err) {
2676
+ console.error("onCrossTabSync callback failed:", err);
2677
+ }
2678
+ }
2679
+ } catch (err) {
2680
+ console.error(`Error handling cross-tab meta update for ${collection}:`, err);
2615
2681
  }
2616
- } catch (err) {
2617
- console.error(`Error handling cross-tab meta update for ${collection}:`, err);
2618
2682
  }
2619
2683
  }
2620
2684
  async handleServerUpdate(payload) {
@@ -2626,14 +2690,13 @@ class SyncedDb {
2626
2690
  const collectionName = payload.collection;
2627
2691
  if (!this.collections.has(collectionName))
2628
2692
  return;
2693
+ const updatedIds = [];
2629
2694
  switch (payload.operation) {
2630
2695
  case "insert": {
2631
2696
  const serverItem = payload.data;
2632
2697
  if (serverItem) {
2633
2698
  await this.handleServerItemInsert(collectionName, serverItem);
2634
- if (serverItem._ts) {
2635
- await this.broadcastMetaUpdate(collectionName, serverItem._ts);
2636
- }
2699
+ updatedIds.push(String(serverItem._id));
2637
2700
  }
2638
2701
  break;
2639
2702
  }
@@ -2643,16 +2706,12 @@ class SyncedDb {
2643
2706
  const localItem = await this.dexieDb.getById(collectionName, deltaData._id);
2644
2707
  if (localItem) {
2645
2708
  await this.handleServerItemUpdate(collectionName, localItem, deltaData);
2646
- if (deltaData._ts) {
2647
- await this.broadcastMetaUpdate(collectionName, deltaData._ts);
2648
- }
2709
+ updatedIds.push(String(deltaData._id));
2649
2710
  } else {
2650
2711
  const fullItem = await this.restInterface.findById(collectionName, deltaData._id);
2651
2712
  if (fullItem) {
2652
2713
  await this.handleServerItemInsert(collectionName, fullItem);
2653
- if (fullItem._ts) {
2654
- await this.broadcastMetaUpdate(collectionName, fullItem._ts);
2655
- }
2714
+ updatedIds.push(String(fullItem._id));
2656
2715
  }
2657
2716
  }
2658
2717
  }
@@ -2661,7 +2720,7 @@ class SyncedDb {
2661
2720
  case "delete": {
2662
2721
  const deleteData = payload.data;
2663
2722
  await this.handleServerItemDelete(collectionName, deleteData._id);
2664
- await this.broadcastMetaUpdate(collectionName, Date.now());
2723
+ updatedIds.push(String(deleteData._id));
2665
2724
  break;
2666
2725
  }
2667
2726
  case "batch": {
@@ -2679,6 +2738,7 @@ class SyncedDb {
2679
2738
  }
2680
2739
  for (const serverItem of inserts) {
2681
2740
  await this.handleServerItemInsert(collectionName, serverItem);
2741
+ updatedIds.push(String(serverItem._id));
2682
2742
  }
2683
2743
  if (updates.length > 0) {
2684
2744
  const updateIds = updates.map((u) => u._id);
@@ -2688,21 +2748,26 @@ class SyncedDb {
2688
2748
  const localItem = localItems[i];
2689
2749
  if (localItem) {
2690
2750
  await this.handleServerItemUpdate(collectionName, localItem, deltaData);
2751
+ updatedIds.push(String(deltaData._id));
2691
2752
  } else {
2692
2753
  const fullItem = await this.restInterface.findById(collectionName, deltaData._id);
2693
2754
  if (fullItem) {
2694
2755
  await this.handleServerItemInsert(collectionName, fullItem);
2756
+ updatedIds.push(String(fullItem._id));
2695
2757
  }
2696
2758
  }
2697
2759
  }
2698
2760
  }
2699
2761
  for (const deleteData of deletes) {
2700
2762
  await this.handleServerItemDelete(collectionName, deleteData._id);
2763
+ updatedIds.push(String(deleteData._id));
2701
2764
  }
2702
- await this.broadcastMetaUpdate(collectionName, Date.now());
2703
2765
  break;
2704
2766
  }
2705
2767
  }
2768
+ if (updatedIds.length > 0) {
2769
+ this.broadcastMetaUpdate({ [collectionName]: updatedIds });
2770
+ }
2706
2771
  }
2707
2772
  async handleServerItemInsert(collectionName, serverItem) {
2708
2773
  const localItem = await this.dexieDb.getById(collectionName, serverItem._id);
@@ -2824,27 +2889,17 @@ var DIRTY_CHANGES_TABLE = "_dirty_changes";
2824
2889
 
2825
2890
  class DexieDb extends Dexie {
2826
2891
  tenant;
2827
- instanceId;
2828
- crossTabSyncDebounceMs;
2829
2892
  collections = new Map;
2830
2893
  syncMeta;
2831
2894
  dirtyChanges;
2832
- broadcastChannel = null;
2833
- pendingBroadcasts = new Map;
2834
- debounceTimer = null;
2835
- metaUpdateCallbacks = new Set;
2836
- constructor(tenant, collectionConfigs, options) {
2895
+ constructor(tenant, collectionConfigs) {
2837
2896
  super(`synced-db-${tenant}`);
2838
2897
  this.tenant = tenant;
2839
- this.instanceId = options?.instanceId ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
2840
- this.crossTabSyncDebounceMs = options?.crossTabSyncDebounceMs ?? 100;
2841
2898
  const schema = {};
2842
2899
  schema[SYNC_META_TABLE] = "[tenant+collection]";
2843
2900
  schema[DIRTY_CHANGES_TABLE] = "[collection+id]";
2844
2901
  for (const config of collectionConfigs) {
2845
- const additionalIndexes = config.indexes || [];
2846
- const indexes = ["_id", "_rev", ...additionalIndexes.map(String)];
2847
- schema[config.name] = indexes.join(", ");
2902
+ schema[config.name] = "_id";
2848
2903
  }
2849
2904
  this.version(1).stores(schema);
2850
2905
  this.syncMeta = this.table(SYNC_META_TABLE);
@@ -2852,48 +2907,6 @@ class DexieDb extends Dexie {
2852
2907
  for (const config of collectionConfigs) {
2853
2908
  this.collections.set(config.name, this.table(config.name));
2854
2909
  }
2855
- this.setupBroadcastChannel();
2856
- }
2857
- setupBroadcastChannel() {
2858
- if (typeof BroadcastChannel === "undefined") {
2859
- return;
2860
- }
2861
- this.broadcastChannel = new BroadcastChannel(`synced-db-meta-${this.tenant}`);
2862
- this.broadcastChannel.onmessage = (event) => {
2863
- const payload = event.data;
2864
- for (const callback of this.metaUpdateCallbacks) {
2865
- try {
2866
- callback(payload);
2867
- } catch (err) {
2868
- console.error("Error in metaUpdate callback:", err);
2869
- }
2870
- }
2871
- };
2872
- }
2873
- queueBroadcast(collection, lastSyncTs) {
2874
- if (!this.broadcastChannel)
2875
- return;
2876
- this.pendingBroadcasts.set(collection, lastSyncTs);
2877
- if (this.debounceTimer) {
2878
- clearTimeout(this.debounceTimer);
2879
- }
2880
- this.debounceTimer = setTimeout(() => {
2881
- this.flushBroadcasts();
2882
- }, this.crossTabSyncDebounceMs);
2883
- }
2884
- flushBroadcasts() {
2885
- if (!this.broadcastChannel)
2886
- return;
2887
- for (const [collection, lastSyncTs] of this.pendingBroadcasts) {
2888
- const payload = {
2889
- collection,
2890
- lastSyncTs,
2891
- instanceId: this.instanceId
2892
- };
2893
- this.broadcastChannel.postMessage(payload);
2894
- }
2895
- this.pendingBroadcasts.clear();
2896
- this.debounceTimer = null;
2897
2910
  }
2898
2911
  getTable(collection) {
2899
2912
  const table = this.collections.get(collection);
@@ -3073,7 +3086,6 @@ class DexieDb extends Dexie {
3073
3086
  collection,
3074
3087
  lastSyncTs
3075
3088
  });
3076
- this.queueBroadcast(collection, lastSyncTs);
3077
3089
  }
3078
3090
  async deleteSyncMeta(collection) {
3079
3091
  await this.syncMeta.delete([this.tenant, collection]);
@@ -3081,30 +3093,6 @@ class DexieDb extends Dexie {
3081
3093
  getTenant() {
3082
3094
  return this.tenant;
3083
3095
  }
3084
- getInstanceId() {
3085
- return this.instanceId;
3086
- }
3087
- async getNewerThan(collection, rev) {
3088
- const table = this.getTable(collection);
3089
- return await table.where("_rev").above(rev).toArray();
3090
- }
3091
- onMetaUpdated(callback) {
3092
- this.metaUpdateCallbacks.add(callback);
3093
- return () => {
3094
- this.metaUpdateCallbacks.delete(callback);
3095
- };
3096
- }
3097
- closeBroadcastChannel() {
3098
- if (this.debounceTimer) {
3099
- clearTimeout(this.debounceTimer);
3100
- this.flushBroadcasts();
3101
- }
3102
- if (this.broadcastChannel) {
3103
- this.broadcastChannel.close();
3104
- this.broadcastChannel = null;
3105
- }
3106
- this.metaUpdateCallbacks.clear();
3107
- }
3108
3096
  }
3109
3097
  // node_modules/msgpackr/unpack.js
3110
3098
  var decoder;
@@ -1,26 +1,18 @@
1
1
  import Dexie from "dexie";
2
- import type { DexieDbOptions, DirtyChange, I_DexieDb, MetaUpdateBroadcast, SyncMeta } from "../types/I_DexieDb";
2
+ import type { DirtyChange, I_DexieDb, SyncMeta } from "../types/I_DexieDb";
3
3
  import type { CollectionConfig } from "../types/CollectionConfig";
4
4
  import type { Id, LocalDbEntity } from "../types/DbEntity";
5
5
  /**
6
6
  * Implementacija DexieDb interface
7
7
  * Uporablja Dexie za dostop do IndexedDB
8
+ * Note: This is pure storage - cross-tab sync is handled by SyncedDb
8
9
  */
9
10
  export declare class DexieDb extends Dexie implements I_DexieDb {
10
11
  private tenant;
11
- private instanceId;
12
- private crossTabSyncDebounceMs;
13
12
  private collections;
14
13
  private syncMeta;
15
14
  private dirtyChanges;
16
- private broadcastChannel;
17
- private pendingBroadcasts;
18
- private debounceTimer;
19
- private metaUpdateCallbacks;
20
- constructor(tenant: string, collectionConfigs: CollectionConfig<any>[], options?: Partial<DexieDbOptions>);
21
- private setupBroadcastChannel;
22
- private queueBroadcast;
23
- private flushBroadcasts;
15
+ constructor(tenant: string, collectionConfigs: CollectionConfig<any>[]);
24
16
  private getTable;
25
17
  private idToString;
26
18
  save<T extends LocalDbEntity>(collection: string, id: Id, data: Partial<T>): Promise<void>;
@@ -56,9 +48,4 @@ export declare class DexieDb extends Dexie implements I_DexieDb {
56
48
  setSyncMeta(collection: string, lastSyncTs: any): Promise<void>;
57
49
  deleteSyncMeta(collection: string): Promise<void>;
58
50
  getTenant(): string;
59
- getInstanceId(): string;
60
- getNewerThan<T extends LocalDbEntity>(collection: string, rev: number): Promise<T[]>;
61
- onMetaUpdated(callback: (payload: MetaUpdateBroadcast) => void): () => void;
62
- /** Close the broadcast channel when done */
63
- closeBroadcastChannel(): void;
64
51
  }
@@ -1,5 +1,6 @@
1
1
  import type { AggregateOptions } from "mongodb";
2
2
  import type { I_SyncedDb, SyncedDbConfig, WsNotificationInfo } from "../types/I_SyncedDb";
3
+ import type { MetaUpdateBroadcast } from "../types/I_DexieDb";
3
4
  import type { QuerySpec, UpdateSpec, InsertSpec, BatchSpec } from "../types/I_RestInterface";
4
5
  import type { Id, DbEntity } from "../types/DbEntity";
5
6
  /**
@@ -33,8 +34,7 @@ export declare class SyncedDb implements I_SyncedDb {
33
34
  private onWsNotification?;
34
35
  private onOnlineStatusChange?;
35
36
  private crossTabSyncDebounceMs;
36
- private onExternalSync?;
37
- private onStaleMetaConflict?;
37
+ private onCrossTabSync?;
38
38
  private onBecameLeader?;
39
39
  private onLostLeadership?;
40
40
  private onInfrastructureError?;
@@ -60,10 +60,12 @@ export declare class SyncedDb implements I_SyncedDb {
60
60
  private readonly syncedDbInstanceId;
61
61
  /** In-memory cache of sync metadata per collection */
62
62
  private syncMetaCache;
63
- /** In-memory cache of max _rev seen per collection (for cross-tab sync) */
64
- private lastSeenRevCache;
65
- /** Unsubscribe function for cross-tab meta updates */
66
- private unsubscribeMetaUpdates?;
63
+ /** BroadcastChannel for cross-tab meta updates */
64
+ private metaUpdateChannel?;
65
+ /** Pending broadcasts to be sent (debounced) */
66
+ private pendingBroadcasts;
67
+ /** Debounce timer for broadcasts */
68
+ private broadcastDebounceTimer?;
67
69
  /** Whether this instance is the leader tab */
68
70
  private isLeader;
69
71
  /** Resolve function to release the leader lock */
@@ -86,6 +88,11 @@ export declare class SyncedDb implements I_SyncedDb {
86
88
  getCrossTabSyncDebounceMs(): number;
87
89
  /** Check if this instance is the leader tab */
88
90
  isLeaderTab(): boolean;
91
+ /**
92
+ * Test helper: simulate receiving a cross-tab broadcast from another instance.
93
+ * This is used in tests where BroadcastChannel is not available.
94
+ */
95
+ simulateExternalBroadcast(payload: MetaUpdateBroadcast): void;
89
96
  /**
90
97
  * Try to become the leader tab using Web Locks API.
91
98
  * Only the leader processes server notifications.
@@ -170,8 +177,8 @@ export declare class SyncedDb implements I_SyncedDb {
170
177
  getOnWsReconnect(): ((attempt: number) => void) | undefined;
171
178
  getOnWsNotification(): ((info: WsNotificationInfo) => void) | undefined;
172
179
  getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
173
- deleteCollectionData(collection: string, force?: boolean): Promise<boolean>;
174
- deleteAllCollectionsData(force?: boolean): Promise<boolean>;
180
+ dropCollection(collection: string, force?: boolean): Promise<void>;
181
+ dropDatabase(force?: boolean): Promise<void>;
175
182
  private assertCollection;
176
183
  private stripLocalFields;
177
184
  private getPendingKey;
@@ -210,16 +217,18 @@ export declare class SyncedDb implements I_SyncedDb {
210
217
  */
211
218
  private timestampsEqual;
212
219
  /**
213
- * Normalize timestamp to number for comparison
220
+ * Notify other tabs about updated records via BroadcastChannel.
221
+ * Does NOT write to Dexie - just sends a broadcast with the updated _ids.
222
+ * Broadcasts are debounced to reduce message frequency.
214
223
  */
215
- private normalizeTimestamp;
224
+ private broadcastMetaUpdate;
216
225
  /**
217
- * Broadcast metadata update to other tabs.
218
- * Updates both Dexie sync meta and the in-memory cache.
226
+ * Flush pending broadcasts immediately.
219
227
  */
220
- private broadcastMetaUpdate;
228
+ private flushBroadcasts;
221
229
  /**
222
- * Handle cross-tab sync metadata updates from BroadcastChannel
230
+ * Handle cross-tab sync metadata updates from BroadcastChannel.
231
+ * Copies the specified objects from Dexie to in-memory and removes deleted records.
223
232
  */
224
233
  private handleCrossTabMetaUpdate;
225
234
  private handleServerUpdate;
@@ -1,7 +1,6 @@
1
1
  import type { QuerySpec } from ".";
2
- export interface CollectionConfig<T> {
2
+ export interface CollectionConfig<T = any> {
3
3
  name: string;
4
4
  query?: QuerySpec<T>;
5
- indexes?: (keyof T)[];
6
5
  resolveSyncConflict?(local: T, external: T): T;
7
6
  }
@@ -29,20 +29,12 @@ export interface DirtyChange {
29
29
  }
30
30
  /**
31
31
  * Payload for cross-tab meta update broadcasts
32
+ * Maps collection names to arrays of _id strings that were updated
32
33
  */
33
34
  export interface MetaUpdateBroadcast {
34
- collection: string;
35
- lastSyncTs: any;
36
- instanceId: string;
37
- }
38
- /**
39
- * Options for DexieDb constructor
40
- */
41
- export interface DexieDbOptions {
42
- /** Unique identifier for this SyncedDb instance */
35
+ /** Map of collection name -> array of _id strings that were updated */
36
+ updates: Record<string, string[]>;
43
37
  instanceId: string;
44
- /** Debounce interval for cross-tab sync broadcasts (ms) */
45
- crossTabSyncDebounceMs: number;
46
38
  }
47
39
  /**
48
40
  * Interface za Dexie (IndexedDB) bazo podatkov
@@ -105,10 +97,4 @@ export interface I_DexieDb {
105
97
  deleteSyncMeta(collection: string): Promise<void>;
106
98
  /** Vrne tenant */
107
99
  getTenant(): string;
108
- /** Get records newer than revision (uses _rev index) */
109
- getNewerThan<T extends LocalDbEntity>(collection: string, rev: number): Promise<T[]>;
110
- /** Subscribe to metadata updates from other tabs. Returns unsubscribe function. */
111
- onMetaUpdated(callback: (payload: MetaUpdateBroadcast) => void): () => void;
112
- /** Get the instance ID */
113
- getInstanceId(): string;
114
100
  }
@@ -201,15 +201,13 @@ export interface SyncInfo {
201
201
  /**
202
202
  * Konfiguracija za posamezno kolekcijo
203
203
  */
204
- export interface CollectionConfig {
204
+ export interface CollectionConfig<T extends DbEntity = any> {
205
205
  /** Ime kolekcije */
206
206
  name: string;
207
207
  /** Opcijski filter za velike tabele - omeji obseg sinhronizacije */
208
208
  query?: QuerySpec<any>;
209
- /** Dodatni indexi poleg _id */
210
- indexes?: string[];
211
209
  /** Opcijska funkcija za razreševanje konfliktov */
212
- resolveSyncConflict?<T extends DbEntity>(local: T, external: T): T;
210
+ resolveSyncConflict?(local: T, external: T): T;
213
211
  }
214
212
  /**
215
213
  * Konfiguracija za SyncedDb - dependencies so podani kot parametri (DI)
@@ -275,9 +273,7 @@ export interface SyncedDbConfig {
275
273
  /** Debounce interval for cross-tab sync broadcasts in ms (default: 100) */
276
274
  crossTabSyncDebounceMs?: number;
277
275
  /** Callback when another tab synced data and local in-memory cache was updated */
278
- onExternalSync?: (collection: string, newItemsCount: number) => void;
279
- /** Callback when received metadata is older than in-memory (indicates conflict/issue) */
280
- onStaleMetaConflict?: (collection: string, inMemoryTs: any, receivedTs: any) => void;
276
+ onCrossTabSync?: (collection: string, newItemsCount: number) => void;
281
277
  /** Callback when this instance becomes the leader tab */
282
278
  onBecameLeader?: () => void;
283
279
  /** Callback when this instance loses leadership (e.g., another tab took over) */
@@ -410,20 +406,28 @@ export interface I_SyncedDb {
410
406
  /** Vrne vse dirty objekte iz vseh kolekcij */
411
407
  getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
412
408
  /**
413
- * Deletes all data for a single collection from Dexie and in-memory.
414
- * First sends all dirty data to the server.
415
- * @param collection Name of the collection to delete
416
- * @param force If true, deletes data even if sending dirty data fails
417
- * @returns true if successful, false if sending failed and force was false
409
+ * Drops a collection, ensuring no data loss.
410
+ * - Throws if offline or forcedOffline (unless force=true)
411
+ * - Flushes pending in-memory changes to Dexie
412
+ * - Syncs all dirty items to the server
413
+ * - Throws if sync fails (unless force=true)
414
+ * - Then deletes collection data, metadata, and dirty changes from Dexie and in-memory
415
+ * @param collection Name of the collection to drop
416
+ * @param force If true, drops even if offline or sync fails (may lose data)
417
+ * @throws Error if offline, forcedOffline, or sync fails (unless force=true)
418
418
  */
419
- deleteCollectionData(collection: string, force?: boolean): Promise<boolean>;
419
+ dropCollection(collection: string, force?: boolean): Promise<void>;
420
420
  /**
421
- * Deletes all data for all collections from Dexie and in-memory.
422
- * First sends all dirty data to the server.
423
- * @param force If true, deletes data even if sending dirty data fails
424
- * @returns true if successful, false if sending failed and force was false
421
+ * Drops all collections (the entire database), ensuring no data loss.
422
+ * - Throws if offline or forcedOffline (unless force=true)
423
+ * - Flushes pending in-memory changes to Dexie
424
+ * - Syncs all dirty items to the server
425
+ * - Throws if sync fails (unless force=true)
426
+ * - Then deletes all collection data, metadata, and dirty changes from Dexie and in-memory
427
+ * @param force If true, drops even if offline or sync fails (may lose data)
428
+ * @throws Error if offline, forcedOffline, or sync fails (unless force=true)
425
429
  */
426
- deleteAllCollectionsData(force?: boolean): Promise<boolean>;
430
+ dropDatabase(force?: boolean): Promise<void>;
427
431
  /**
428
432
  * Check if this instance is the leader tab.
429
433
  * Only the leader processes server notifications and writes to Dexie from server updates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.47",
3
+ "version": "0.1.50",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",